From a2645615761a1e8665caa16de90a92e67e41b2a9 Mon Sep 17 00:00:00 2001 From: shuim Date: Sat, 10 Oct 2026 12:17:49 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=95=98=EB=93=9C=EB=8B=9D=20=EB=A3=A8?= =?UTF-8?q?=ED=94=84=208=ED=9A=8C=EC=B0=A8=20=E2=80=94=20=EC=B2=B4?= =?UTF-8?q?=EA=B2=B0=20=EB=B3=B4=EC=A0=95=C2=B7=EA=B3=A0=EC=95=84=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=C2=B7=EC=B2=AD=EC=82=B0=20=EA=B2=BD=EB=B3=B4?= =?UTF-8?q?=C2=B7=EC=8B=A4=EC=8B=9C=EA=B0=84/=EC=B0=A8=ED=8A=B8=C2=B7?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=81=AC=20=EA=B0=80=EB=93=9C=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN --- .claude/agents/data-integrity-agent.md | 36 + .claude/agents/daytrading-agent.md | 52 + .claude/agents/ops-reliability-agent.md | 29 + .claude/agents/red-team-agent.md | 32 + .claude/agents/scalping-agent.md | 37 + .claude/agents/swing-trader-agent.md | 44 + .claude/hooks/tests/test_hooks.py | 35 +- .claude/scripts/prod_smoke.sh | 33 + backend/app/api/endpoints/analysis.py | 18 +- backend/app/api/endpoints/day_trading.py | 1003 ++++----- backend/app/api/endpoints/manual_buy.py | 70 +- backend/app/api/endpoints/market_data.py | 626 +++++- .../app/api/endpoints/market_intelligence.py | 16 +- backend/app/api/endpoints/portfolio.py | 569 ++++- backend/app/api/endpoints/portfolio_v2.py | 97 +- backend/app/api/endpoints/recommendations.py | 315 ++- backend/app/api/endpoints/risk_management.py | 302 ++- backend/app/api/endpoints/strategy_config.py | 22 +- backend/app/api/endpoints/swing_portfolio.py | 76 +- backend/app/api/endpoints/trades.py | 26 +- backend/app/api/endpoints/trades_v2.py | 5 +- backend/app/core/background_tasks.py | 27 + backend/app/core/config.py | 19 +- backend/app/core/heartbeat.py | 31 + backend/app/core/redis.py | 56 +- backend/app/core/security.py | 21 + backend/app/core/trade_status.py | 9 + backend/app/main.py | 973 ++++++++- backend/app/services/adaptive_strategy.py | 177 +- backend/app/services/ai_strategy_service.py | 49 +- backend/app/services/analysis_db_service.py | 5 +- backend/app/services/correlation_filter.py | 273 ++- backend/app/services/day_trading_strategy.py | 4 +- .../app/services/daytrading/entry_service.py | 1277 +++++++++++- .../app/services/daytrading/exit_service.py | 1857 ++++++++++++++++- .../services/daytrading/fill_reconciler.py | 122 +- .../services/daytrading/position_manager.py | 634 +++++- backend/app/services/daytrading/scheduler.py | 382 +++- backend/app/services/db_worker.py | 437 +++- backend/app/services/execution_quality.py | 77 + backend/app/services/kis_realtime_stream.py | 694 ++++-- backend/app/services/korea_investment_api.py | 87 +- backend/app/services/kr_backtest_engine.py | 448 +++- backend/app/services/loss_tracker.py | 322 ++- backend/app/services/market_analyzer.py | 293 ++- backend/app/services/mdd_manager.py | 289 ++- backend/app/services/mdd_state_backup.py | 192 ++ .../app/services/mean_reversion_strategy.py | 513 ++++- backend/app/services/ml_retrain_pipeline.py | 110 +- backend/app/services/naver_finance.py | 171 +- backend/app/services/news_sentiment.py | 262 ++- backend/app/services/schedulers.py | 752 ++++++- backend/app/services/sector_mapper.py | 104 + .../app/services/strategy_config_service.py | 135 +- backend/app/services/swing/entry_service.py | 179 +- backend/app/services/swing/exit_service.py | 1231 ++++++++++- backend/app/services/swing/fill_sweep.py | 452 ++++ .../app/services/swing/position_manager.py | 264 ++- backend/app/services/swing/scheduler.py | 1048 +++++++++- backend/app/services/trade_analyzer.py | 650 ++++++ backend/app/services/trade_executor.py | 1411 ++++++++++++- backend/app/services/trade_helpers.py | 57 +- backend/app/services/us_market_scheduler.py | 27 + backend/app/services/walk_forward.py | 127 +- backend/app/services/websocket_manager.py | 71 +- backend/backfill_swing_entry_fill.py | 52 + backend/batch_daily_analysis.py | 93 +- backend/collect_krx_data.py | 456 +++- backend/run_backtest.py | 177 +- .../test_adaptive_min_bars_r3_20261009.py | 55 + .../test_adaptive_staleness_r2_20261009.py | 83 + .../tests/test_ai_pre_market_latest_date.py | 65 + .../tests/test_analysis_db_updated_at_kst.py | 19 + .../test_analysis_endpoint_v2_20261009.py | 30 + backend/tests/test_api_endpoints.py | 2 +- .../test_background_tasks_r3_20261009.py | 46 + backend/tests/test_batch_daily_kis_record.py | 83 + .../test_bear_v2_hardening_r2_20261009.py | 105 + backend/tests/test_bugfixes_20260923.py | 23 +- backend/tests/test_collect_krx_data_gaps.py | 346 +++ backend/tests/test_correlation_filter.py | 301 ++- ...test_daytrading_watchlist_size_20260930.py | 13 +- ...db_worker_backlog_success_path_20261009.py | 46 + .../test_db_worker_hardening_20261009.py | 205 ++ .../test_db_worker_stock_ban_tz_20261010.py | 37 + .../test_db_worker_write_order_20261009.py | 113 + .../tests/test_display_data_fixes_20261008.py | 384 ++++ ...dt_api_engine_signals_closeall_20261009.py | 320 +++ ...test_dt_daily_state_rebuild_r2_20261009.py | 274 +++ .../test_dt_entry_executed_at_r8_20261010.py | 91 + .../test_dt_entry_hardening_r2_20261009.py | 435 ++++ .../test_dt_entry_hardening_r3_20261009.py | 265 +++ .../test_dt_entry_hardening_r5_20261009.py | 153 ++ ...est_dt_entry_portfolio_caps_r1_20261009.py | 195 ++ ..._entry_unknown_and_fresh_price_20261009.py | 483 +++++ ...test_dt_exit_hardening_loop_r2_20261009.py | 245 +++ .../test_dt_exit_hardening_r1_20261009.py | 419 ++++ .../test_dt_exit_hardening_r2_20261009.py | 295 +++ .../test_dt_exit_hardening_r3_20261009.py | 381 ++++ .../test_dt_exit_hardening_r4_20261009.py | 194 ++ .../test_dt_exit_partial_fill_pnl_20261009.py | 128 ++ .../test_dt_exit_reject_alert_r4_20261009.py | 181 ++ .../test_dt_exit_rsi_warmup_r3_20261009.py | 200 ++ .../test_dt_fill_late_recheck_r1_20261009.py | 207 ++ .../test_dt_orb_bar_closed_r6_20261010.py | 130 ++ .../tests/test_dt_orphan_timestop_20261008.py | 456 ++++ .../tests/test_dt_orphan_today_r6_20261010.py | 322 +++ ...st_dt_restore_closed_today_r2h_20261009.py | 283 +++ .../test_dt_restore_verify_r3_20261009.py | 271 +++ .../tests/test_dt_safety_fixes_20261002.py | 126 +- .../tests/test_dt_scheduler_alert_tasks_r3.py | 98 + ...est_dt_scheduler_exit_priority_20261009.py | 459 ++++ ...st_dt_stale_pnl_daily_reset_r7_20261010.py | 94 + ...t_dt_stale_unfilled_restore_r8_20261010.py | 298 +++ ...st_dt_trailing_high_post_entry_20261001.py | 3 + .../test_dt_warmup_and_orb_volume_20260929.py | 17 +- backend/tests/test_execution_quality.py | 44 + .../test_fill_price_avg_split_20261008.py | 479 +++++ .../test_fill_sweep_idempotent_r5_20261009.py | 120 ++ .../tests/test_fill_sweep_post_close_r4.py | 217 ++ .../tests/test_fill_sweep_target_20261009.py | 222 ++ .../tests/test_generic_rule_dispatch_r1.py | 51 + .../test_guard_bypass_endpoints_20261002.py | 127 +- backend/tests/test_health_watch_script.py | 41 + .../test_infra_security_round2_20261002.py | 97 + .../tests/test_journal_intraday_20261001.py | 2 +- .../test_kis_breaker_alert_task_ref_r6.py | 38 + backend/tests/test_kis_breaker_orders_r4.py | 98 + ...test_kr_backtest_adjust_regime_20261009.py | 63 + .../test_kr_backtest_exit_v2_20261009.py | 86 + .../tests/test_kr_backtest_filler_20261009.py | 93 + ..._kr_backtest_intraday_timestop_20261009.py | 168 ++ ...est_loss_tracker_ban_preserved_20261009.py | 48 + ...t_loss_tracker_load_failclosed_20261009.py | 147 ++ ..._loss_tracker_retro_correction_20261010.py | 62 + .../tests/test_main_db_worker_restart_r2.py | 87 + backend/tests/test_main_health_exposure_r4.py | 136 ++ backend/tests/test_main_heartbeat_r1.py | 287 +++ backend/tests/test_main_migration_head_r6.py | 146 ++ .../test_main_shutdown_ws_r3_20261009.py | 170 ++ .../test_market_analyzer_krx_merge_r2.py | 76 + .../tests/test_market_analyzer_offload_r4.py | 43 + .../tests/test_market_analyzer_volratio_r3.py | 40 + .../tests/test_market_intelligence_news.py | 49 + .../tests/test_market_regime_offhours_r2.py | 30 + .../tests/test_mdd_active_trade_scope_r5.py | 71 + .../tests/test_mdd_alert_retry_r4_20261009.py | 59 + .../tests/test_mdd_empty_account_stale_r8.py | 38 + .../test_mdd_reduction_net_pnl_r4_20261009.py | 88 + .../test_mdd_reduction_retry_20261002.py | 100 +- backend/tests/test_mdd_state_backup.py | 166 ++ backend/tests/test_mean_reversion.py | 69 + .../tests/test_ml_retrain_price_fallback.py | 120 ++ ...r_backtest_entry_deterministic_20261009.py | 173 ++ ...t_mr_full_exit_profit_guard_r6_20261010.py | 113 + backend/tests/test_naver_close_quality_r4.py | 97 + .../test_news_sentiment_round3_20261009.py | 81 + .../test_news_sentiment_round8_market_json.py | 89 + backend/tests/test_p1_audit_fixes.py | 26 +- backend/tests/test_p3_audit_fixes.py | 6 +- backend/tests/test_portfolio_cache_bridge.py | 23 + backend/tests/test_portfolio_hardening_r1.py | 198 ++ backend/tests/test_portfolio_v2_pending_r7.py | 122 ++ .../test_realtime_chart_fixes_20261009.py | 595 ++++++ ...est_recommendations_price_date_20261009.py | 47 + backend/tests/test_regime_kis_fallback_r3.py | 101 + .../tests/test_restart_durability_20260928.py | 2 +- .../test_risk_management_endpoints_r4.py | 224 ++ .../tests/test_risk_metrics_fill_order_r8.py | 38 + backend/tests/test_round4_fixes_20261002.py | 6 + backend/tests/test_round5_fixes_20261002.py | 4 + ...test_run_backtest_pit_universe_20261009.py | 70 + .../tests/test_schedulers_entry_funnel_r4.py | 85 + backend/tests/test_schedulers_hardening_r1.py | 246 +++ .../test_sector_mapper_cache_health_r3.py | 68 + .../tests/test_sell_active_trade_target_r8.py | 89 + backend/tests/test_sideways_message_r6.py | 22 + ...st_strategy_config_dt_readonly_20261009.py | 147 ++ ..._strategy_config_refresh_guard_20261009.py | 43 + .../test_swing_entry_hardening_r2_20261009.py | 243 +++ .../test_swing_exit_cooldown_r8_20261010.py | 196 ++ .../test_swing_exit_hardening_r1_20261009.py | 348 +++ .../test_swing_exit_hardening_r1b_20261009.py | 243 +++ .../test_swing_exit_hardening_r2_20261009.py | 253 +++ .../test_swing_exit_hardening_r4_20261009.py | 303 +++ .../test_swing_exit_rsi_warmup_r4_20261009.py | 297 +++ .../test_swing_hardening_r3_cap_20261009.py | 157 ++ .../test_swing_mdd_input_guard_r2_20261009.py | 240 +++ ...test_swing_partial_exit_no_active_trade.py | 27 +- .../test_swing_pm_hardening_r1_20261009.py | 218 ++ backend/tests/test_swing_r3_fixes_20261002.py | 47 +- .../test_swing_r5_pending_sell_20261002.py | 134 ++ .../test_swing_scheduler_hardening_r1.py | 623 ++++++ ...t_swing_scheduler_hardening_r3_20261009.py | 328 +++ .../test_swing_trailing_mult_20261009.py | 157 ++ .../tests/test_trade_analyzer_v2_20261009.py | 119 ++ ...t_trade_executor_buy_ledger_r7_20261010.py | 148 ++ .../test_trade_executor_capital_guards_r6.py | 106 + ...ade_executor_entry_backfill_r3_20261009.py | 196 ++ ...st_trade_executor_hardening_r1_20261009.py | 216 ++ ...st_trade_executor_hardening_r2_20261009.py | 219 ++ ...cutor_levels_tasks_slippage_r1_20261009.py | 221 ++ ...est_trade_executor_net_loss_r1_20261009.py | 180 ++ ...test_trade_executor_risk_fixes_20261001.py | 12 +- ...xecutor_sellday_unknown_buy_r1_20261009.py | 324 +++ ...trade_executor_volume_floor_r5_20261009.py | 151 ++ ...test_trade_helpers_surge_trend_20261009.py | 61 + ..._trades_delete_pending_keeps_pending_r7.py | 54 + backend/tests/test_us_entry_ban_gate.py | 56 + .../test_walk_forward_engine_v2_20261009.py | 82 + docker-compose.yml | 52 + frontend/src/components/ChartPanel.tsx | 69 +- frontend/src/components/ConfirmDialog.tsx | 75 + frontend/src/components/JournalPanel.tsx | 77 +- frontend/src/components/PortfolioPanel.tsx | 149 +- frontend/src/components/PortfolioSummary.tsx | 29 +- .../src/components/RecommendationPanel.tsx | 152 +- frontend/src/components/TradingChart.tsx | 155 +- frontend/src/hooks/useMarketData.ts | 47 +- frontend/src/hooks/useWebSocket.ts | 18 +- frontend/src/lib/api.ts | 13 +- frontend/src/main.tsx | 13 +- frontend/src/pages/Dashboard.tsx | 246 ++- frontend/src/pages/MCPStatus.tsx | 4 +- frontend/src/pages/TradeHistory.tsx | 17 +- frontend/src/types/trading.ts | 37 +- frontend/src/utils/formatUtils.ts | 16 + frontend/src/utils/marketHours.ts | 36 + frontend/src/utils/portfolioGrouping.ts | 16 +- scripts/health_watch.py | 102 + 230 files changed, 41160 insertions(+), 2334 deletions(-) create mode 100644 .claude/agents/data-integrity-agent.md create mode 100644 .claude/agents/daytrading-agent.md create mode 100644 .claude/agents/ops-reliability-agent.md create mode 100644 .claude/agents/red-team-agent.md create mode 100644 .claude/agents/scalping-agent.md create mode 100644 .claude/agents/swing-trader-agent.md create mode 100644 backend/app/core/background_tasks.py create mode 100644 backend/app/core/heartbeat.py create mode 100644 backend/app/core/trade_status.py create mode 100644 backend/app/services/execution_quality.py create mode 100644 backend/app/services/mdd_state_backup.py create mode 100644 backend/app/services/swing/fill_sweep.py create mode 100644 backend/backfill_swing_entry_fill.py create mode 100644 backend/tests/test_adaptive_min_bars_r3_20261009.py create mode 100644 backend/tests/test_adaptive_staleness_r2_20261009.py create mode 100644 backend/tests/test_ai_pre_market_latest_date.py create mode 100644 backend/tests/test_analysis_db_updated_at_kst.py create mode 100644 backend/tests/test_analysis_endpoint_v2_20261009.py create mode 100644 backend/tests/test_background_tasks_r3_20261009.py create mode 100644 backend/tests/test_batch_daily_kis_record.py create mode 100644 backend/tests/test_bear_v2_hardening_r2_20261009.py create mode 100644 backend/tests/test_collect_krx_data_gaps.py create mode 100644 backend/tests/test_db_worker_backlog_success_path_20261009.py create mode 100644 backend/tests/test_db_worker_hardening_20261009.py create mode 100644 backend/tests/test_db_worker_stock_ban_tz_20261010.py create mode 100644 backend/tests/test_db_worker_write_order_20261009.py create mode 100644 backend/tests/test_display_data_fixes_20261008.py create mode 100644 backend/tests/test_dt_api_engine_signals_closeall_20261009.py create mode 100644 backend/tests/test_dt_daily_state_rebuild_r2_20261009.py create mode 100644 backend/tests/test_dt_entry_executed_at_r8_20261010.py create mode 100644 backend/tests/test_dt_entry_hardening_r2_20261009.py create mode 100644 backend/tests/test_dt_entry_hardening_r3_20261009.py create mode 100644 backend/tests/test_dt_entry_hardening_r5_20261009.py create mode 100644 backend/tests/test_dt_entry_portfolio_caps_r1_20261009.py create mode 100644 backend/tests/test_dt_entry_unknown_and_fresh_price_20261009.py create mode 100644 backend/tests/test_dt_exit_hardening_loop_r2_20261009.py create mode 100644 backend/tests/test_dt_exit_hardening_r1_20261009.py create mode 100644 backend/tests/test_dt_exit_hardening_r2_20261009.py create mode 100644 backend/tests/test_dt_exit_hardening_r3_20261009.py create mode 100644 backend/tests/test_dt_exit_hardening_r4_20261009.py create mode 100644 backend/tests/test_dt_exit_partial_fill_pnl_20261009.py create mode 100644 backend/tests/test_dt_exit_reject_alert_r4_20261009.py create mode 100644 backend/tests/test_dt_exit_rsi_warmup_r3_20261009.py create mode 100644 backend/tests/test_dt_fill_late_recheck_r1_20261009.py create mode 100644 backend/tests/test_dt_orb_bar_closed_r6_20261010.py create mode 100644 backend/tests/test_dt_orphan_timestop_20261008.py create mode 100644 backend/tests/test_dt_orphan_today_r6_20261010.py create mode 100644 backend/tests/test_dt_restore_closed_today_r2h_20261009.py create mode 100644 backend/tests/test_dt_restore_verify_r3_20261009.py create mode 100644 backend/tests/test_dt_scheduler_alert_tasks_r3.py create mode 100644 backend/tests/test_dt_scheduler_exit_priority_20261009.py create mode 100644 backend/tests/test_dt_stale_pnl_daily_reset_r7_20261010.py create mode 100644 backend/tests/test_dt_stale_unfilled_restore_r8_20261010.py create mode 100644 backend/tests/test_execution_quality.py create mode 100644 backend/tests/test_fill_price_avg_split_20261008.py create mode 100644 backend/tests/test_fill_sweep_idempotent_r5_20261009.py create mode 100644 backend/tests/test_fill_sweep_post_close_r4.py create mode 100644 backend/tests/test_fill_sweep_target_20261009.py create mode 100644 backend/tests/test_generic_rule_dispatch_r1.py create mode 100644 backend/tests/test_health_watch_script.py create mode 100644 backend/tests/test_kis_breaker_alert_task_ref_r6.py create mode 100644 backend/tests/test_kis_breaker_orders_r4.py create mode 100644 backend/tests/test_kr_backtest_adjust_regime_20261009.py create mode 100644 backend/tests/test_kr_backtest_exit_v2_20261009.py create mode 100644 backend/tests/test_kr_backtest_filler_20261009.py create mode 100644 backend/tests/test_kr_backtest_intraday_timestop_20261009.py create mode 100644 backend/tests/test_loss_tracker_ban_preserved_20261009.py create mode 100644 backend/tests/test_loss_tracker_load_failclosed_20261009.py create mode 100644 backend/tests/test_loss_tracker_retro_correction_20261010.py create mode 100644 backend/tests/test_main_db_worker_restart_r2.py create mode 100644 backend/tests/test_main_health_exposure_r4.py create mode 100644 backend/tests/test_main_heartbeat_r1.py create mode 100644 backend/tests/test_main_migration_head_r6.py create mode 100644 backend/tests/test_main_shutdown_ws_r3_20261009.py create mode 100644 backend/tests/test_market_analyzer_krx_merge_r2.py create mode 100644 backend/tests/test_market_analyzer_offload_r4.py create mode 100644 backend/tests/test_market_analyzer_volratio_r3.py create mode 100644 backend/tests/test_market_intelligence_news.py create mode 100644 backend/tests/test_market_regime_offhours_r2.py create mode 100644 backend/tests/test_mdd_active_trade_scope_r5.py create mode 100644 backend/tests/test_mdd_alert_retry_r4_20261009.py create mode 100644 backend/tests/test_mdd_empty_account_stale_r8.py create mode 100644 backend/tests/test_mdd_reduction_net_pnl_r4_20261009.py create mode 100644 backend/tests/test_mdd_state_backup.py create mode 100644 backend/tests/test_ml_retrain_price_fallback.py create mode 100644 backend/tests/test_mr_backtest_entry_deterministic_20261009.py create mode 100644 backend/tests/test_mr_full_exit_profit_guard_r6_20261010.py create mode 100644 backend/tests/test_naver_close_quality_r4.py create mode 100644 backend/tests/test_news_sentiment_round3_20261009.py create mode 100644 backend/tests/test_news_sentiment_round8_market_json.py create mode 100644 backend/tests/test_portfolio_hardening_r1.py create mode 100644 backend/tests/test_portfolio_v2_pending_r7.py create mode 100644 backend/tests/test_realtime_chart_fixes_20261009.py create mode 100644 backend/tests/test_recommendations_price_date_20261009.py create mode 100644 backend/tests/test_regime_kis_fallback_r3.py create mode 100644 backend/tests/test_risk_management_endpoints_r4.py create mode 100644 backend/tests/test_risk_metrics_fill_order_r8.py create mode 100644 backend/tests/test_run_backtest_pit_universe_20261009.py create mode 100644 backend/tests/test_schedulers_entry_funnel_r4.py create mode 100644 backend/tests/test_schedulers_hardening_r1.py create mode 100644 backend/tests/test_sector_mapper_cache_health_r3.py create mode 100644 backend/tests/test_sell_active_trade_target_r8.py create mode 100644 backend/tests/test_sideways_message_r6.py create mode 100644 backend/tests/test_strategy_config_dt_readonly_20261009.py create mode 100644 backend/tests/test_strategy_config_refresh_guard_20261009.py create mode 100644 backend/tests/test_swing_entry_hardening_r2_20261009.py create mode 100644 backend/tests/test_swing_exit_cooldown_r8_20261010.py create mode 100644 backend/tests/test_swing_exit_hardening_r1_20261009.py create mode 100644 backend/tests/test_swing_exit_hardening_r1b_20261009.py create mode 100644 backend/tests/test_swing_exit_hardening_r2_20261009.py create mode 100644 backend/tests/test_swing_exit_hardening_r4_20261009.py create mode 100644 backend/tests/test_swing_exit_rsi_warmup_r4_20261009.py create mode 100644 backend/tests/test_swing_hardening_r3_cap_20261009.py create mode 100644 backend/tests/test_swing_mdd_input_guard_r2_20261009.py create mode 100644 backend/tests/test_swing_pm_hardening_r1_20261009.py create mode 100644 backend/tests/test_swing_scheduler_hardening_r1.py create mode 100644 backend/tests/test_swing_scheduler_hardening_r3_20261009.py create mode 100644 backend/tests/test_swing_trailing_mult_20261009.py create mode 100644 backend/tests/test_trade_analyzer_v2_20261009.py create mode 100644 backend/tests/test_trade_executor_buy_ledger_r7_20261010.py create mode 100644 backend/tests/test_trade_executor_capital_guards_r6.py create mode 100644 backend/tests/test_trade_executor_entry_backfill_r3_20261009.py create mode 100644 backend/tests/test_trade_executor_hardening_r1_20261009.py create mode 100644 backend/tests/test_trade_executor_hardening_r2_20261009.py create mode 100644 backend/tests/test_trade_executor_levels_tasks_slippage_r1_20261009.py create mode 100644 backend/tests/test_trade_executor_net_loss_r1_20261009.py create mode 100644 backend/tests/test_trade_executor_sellday_unknown_buy_r1_20261009.py create mode 100644 backend/tests/test_trade_executor_volume_floor_r5_20261009.py create mode 100644 backend/tests/test_trade_helpers_surge_trend_20261009.py create mode 100644 backend/tests/test_trades_delete_pending_keeps_pending_r7.py create mode 100644 backend/tests/test_us_entry_ban_gate.py create mode 100644 backend/tests/test_walk_forward_engine_v2_20261009.py create mode 100644 frontend/src/components/ConfirmDialog.tsx create mode 100644 frontend/src/utils/marketHours.ts create mode 100644 scripts/health_watch.py diff --git a/.claude/agents/data-integrity-agent.md b/.claude/agents/data-integrity-agent.md new file mode 100644 index 0000000..a443524 --- /dev/null +++ b/.claude/agents/data-integrity-agent.md @@ -0,0 +1,36 @@ +--- +name: data-integrity-agent +model: opus +description: | + 시장 데이터 정합성 에이전트. 일봉·분봉·실시간 틱·호가·지수·종목명·섹터 데이터의 누락·지연·오염을 + 탐지하고, 지표(RSI/BB/ATR/ADX)와 백테스트 입력 데이터의 품질을 보증합니다. + 다음 상황에서 사용하세요: + - stock_daily_prices 누락일·0 OHLC·액면분할 미반영·상장폐지 종목 처리 + - 실시간 스트림(KIS WS) 구독·파싱·지연, 차트 데이터 불일치 + - 백테스트 입력 데이터 커버리지·생존편향·지수(KOSPI200) 데이터 확보 +tools: + - Read + - Glob + - Grep + - Bash + - Edit + - Write +--- + +당신은 auto-stock 의 **데이터 정합성 담당**입니다. 데이터 품질이 모든 판단의 전제입니다. 작업 전 `CLAUDE.md` §1·§7·§8.4 를 읽으십시오. + +## 담당 +- `backend/app/api/endpoints/market_data.py`(차트), `services/kis_realtime_stream.py`, `services/naver_finance.py`, `services/korea_investment_api.py`(조회 계열), `backend/collect_krx_data.py`, `services/indicators_core.py`, `services/sector_mapper.py`. + +## 점검 목록 +1. 일봉: 종목별 거래일 누락, 0/결측 OHLC, 비정상 점프(액면분할·병합 미보정 → ±30% 이상 단일 봉), 최근 거래일 지연. +2. 생존편향: 백테스트 유니버스가 "현재 상장 종목"만인지, 상장폐지 종목 포함 여부. +3. 지수: 국면 판정(§6.1)에 필요한 KOSPI200 일봉 출처와 기간 — DB 에 없으면 출처를 찾고 확보 방안(수집 스크립트) 제시. +4. 실시간: 다건 프레임·구독 한도·정상 close 재구독(2026-10-09 수정됨) 회귀 여부. +5. 지표는 `indicators_core` 단일 출처인지(백테스트 ↔ 실전 SMA/Wilder 불일치 §6.2). + +## 운영 DB +읽기 전용(메모리 `nas-readonly-db-access`). 비밀번호 출력·sudo 금지. 외부 수집이 필요하면 **스크립트를 작성하고 실행은 사용자 승인 후**. + +## 반환 형식 +항목별 측정치(쿼리·수치) / 결함(파일:라인) / 백테스트에 미치는 영향 / 확보·수정 방안. diff --git a/.claude/agents/daytrading-agent.md b/.claude/agents/daytrading-agent.md new file mode 100644 index 0000000..03353b4 --- /dev/null +++ b/.claude/agents/daytrading-agent.md @@ -0,0 +1,52 @@ +--- +name: daytrading-agent +model: opus +description: | + 데이트레이딩(5분봉 장중) 전문 에이전트. ORB·VWAP·RSI_BB 진입, 시간손절·트레일링·EOD 청산, + DT 인메모리 상태·고아 잔고·체결 보정, DT 성과 분석을 담당합니다. + 다음 상황에서 사용하세요: + - DT 진입/청산 조건, 손절폭·목표(R)·시간손절 파라미터 검토·수정 + - DT 포지션 상태(인메모리 ↔ ActiveTrade ↔ Portfolio ↔ KIS 잔고) 불일치·고아 잔고 + - 분봉 데이터(1분/5분) 정합성, ORB 레인지·VWAP 계산 + - DT 실거래 손익 분해(청산 사유별·전략별·시간대별), 비용·슬리피지 영향 +tools: + - Read + - Glob + - Grep + - Bash + - Edit + - Write +--- + +당신은 auto-stock 의 **데이트레이딩 전문가**입니다. 작업 전 `CLAUDE.md` §3·§5·§7·§8 을 반드시 읽으십시오. + +## 담당 파일 + +| 파일 | 역할 | +|------|------| +| `backend/app/services/day_trading_strategy.py` | ORB / VWAP / RSI_BB 신호, `check_day_trade_exit`(손절·1.5R·트레일링·시간손절·RSI 과열) — 순수 로직 | +| `backend/app/services/daytrading/entry_service.py` | 감시종목·진입 실행·사이징(종목당 6%, DT 합 30%) | +| `backend/app/services/daytrading/exit_service.py` | 청산 실행, `close_stale_positions`(전일·고아 잔고), 폴백 손절, `_finalize_exit_impl` | +| `backend/app/services/daytrading/position_manager.py` | 인메모리 상태, `restore_from_db`, 당일 연패 | +| `backend/app/services/daytrading/scheduler.py` | 30초 사이클, 15:00 진입 마감, 15:20 EOD | +| `backend/app/services/daytrading/fill_reconciler.py` | ODNO 매칭 체결가 보정, 0체결 복원(`schedule_v2`) | +| `backend/app/api/endpoints/day_trading.py` | `/daytrading/*` 상태·제어 API | + +## 반드시 지킬 것 (요약 — 원문은 CLAUDE.md) + +- **실시간 계층 DB 쓰기 금지**(§3.1). 읽기는 §3.2 ③ 의 허용 함수 안에서만, 쓰기는 `db_task_queue`. +- **최소 손절폭 2% 는 의도적으로 "느슨한 쪽"(`min`)** 이다(§7). 버그로 보고 뒤집지 말 것. +- 같은 종목 스윙 보유분 차감(§3.5, `not_dt_strategy_clause`), KIS 매도가능 수량 상한. +- 전일·고아 청산 손익은 당일 실현손익에 넣지 않는다(`count_daily_pnl=False`). +- **시간손절은 loss_tracker 에 기록하지 않는다**(§5.1 의도적 예외). +- 파라미터(`DAY_TRADE_*`)를 바꾸려면 §0 세 소스 확인 + **백테스트 근거**. 근거 없으면 바꾸지 말고 제안만. +- 테스트 목은 공통 종단(`_execute_exit_impl`, `safe_place_order`)에 **예외 발생형**으로(§4.1). + +## 성과 판단 기준 + +- 운영 실측(2026-04~10-08): DT 163건 순손익 **+31,233원**, 승률 45%, 평균 이익 +3,678 / 평균 손실 −2,636. 시간손절이 손실 건수의 대부분이지만 전체로는 이익이었다. **하루 결과로 구조를 판단하지 말 것** — 최소 수십 거래 단위 실측(`trade_history` where strategy_type='daytrading')으로 판단. +- 운영 DB 조회는 읽기 전용 경로만(메모리 `nas-readonly-db-access`). + +## 반환 형식 + +변경 파일 목록 / 원인(코드 근거 `파일:라인`) / 변경 요약 / 테스트 결과 / 확인 못 한 것(추정 표시) / 남은 리스크. diff --git a/.claude/agents/ops-reliability-agent.md b/.claude/agents/ops-reliability-agent.md new file mode 100644 index 0000000..0ecd780 --- /dev/null +++ b/.claude/agents/ops-reliability-agent.md @@ -0,0 +1,29 @@ +--- +name: ops-reliability-agent +model: sonnet +description: | + 운영 안정성 에이전트. 스케줄러·KIS 토큰·주문 결과 불명·DB 워커·배포·재기동·모니터링 등 + "전략이 맞아도 시스템이 멈추거나 오작동하는" 경로를 점검합니다. + 다음 상황에서 사용하세요: + - 스케줄러 사이클 누락·중복·예외 삼킴, 장애 시 fail-closed 여부 + - KIS 토큰 충돌·계좌 교체(40910000)·호출 한도, 결과 불명 주문(§8.5-33·34) + - db_worker 큐 적체·DLQ, 재기동 시 상태 복원, 배포 순서(§1)와 스모크 + - 알림(텔레그램)·헬스체크·로그로 이상을 즉시 식별할 수 있는지 +tools: + - Read + - Glob + - Grep + - Bash +--- + +당신은 auto-stock 의 **운영 안정성(SRE) 담당**입니다. **파일을 수정하지 않고** 결함과 개선안을 보고합니다. 작업 전 `CLAUDE.md` §1·§3·§5.3·§8.5·§9 를 읽으십시오. + +## 점검 목록 +1. 각 스케줄러(`main.py`, `schedulers.py`, `swing/scheduler.py`, `daytrading/scheduler.py`, MDD)의 예외 처리: 리스크 가드가 예외로 통과되는지(fail-open), 청산 경로가 예외로 멈추는지. +2. 결과 불명 주문·0체결·부분체결이 상태(인메모리/ActiveTrade/Portfolio/KIS)를 어긋나게 만드는 경로와 복구 경로. +3. 재기동(장중 배포) 시 상태 복원 순서·누락, `alembic upgrade` 선행(§1), 30초 캐시. +4. 관측 가능성: 매수 0건·손절 지연·MDD blind·스트림 정지를 로그/헬스/알림으로 몇 분 안에 알 수 있는지. +5. 운영 DB 로 실측 가능하면 읽기 전용으로 확인(메모리 `nas-readonly-db-access`, 비밀번호 출력·sudo 금지). 로컬 백엔드 기동 금지. + +## 반환 형식 +[Critical/High/Medium/Low] 파일:라인, 장애 시나리오, 탐지 가능성(현재), 개선안, 확신도(확정/추정). diff --git a/.claude/agents/red-team-agent.md b/.claude/agents/red-team-agent.md new file mode 100644 index 0000000..8462fe1 --- /dev/null +++ b/.claude/agents/red-team-agent.md @@ -0,0 +1,32 @@ +--- +name: red-team-agent +model: opus +description: | + 레드팀(반증) 에이전트. 다른 에이전트의 보고·주장·수정안을 하나씩 코드·테스트·DB 로 재현해 + 할루시네이션과 과장을 걸러냅니다. 검증 루프의 최종 관문입니다. + 다음 상황에서 사용하세요: + - 에이전트가 "고쳤다/원인은 X다/N건 통과"라고 보고했을 때 그 주장을 독립 검증 + - 수정이 새 결함(과다매도·가드 무력화·실호출·계약 불일치)을 만들었는지 적대적 탐색 + - 루프 종료 판정(Critical/High 0건 2회 연속) 전 마지막 확인 +tools: + - Read + - Glob + - Grep + - Bash +--- + +당신은 auto-stock 의 **레드팀**입니다. 목표는 다른 에이전트가 틀렸음을 증명하는 것입니다. 증명하지 못한 주장만 살아남습니다. **파일을 수정하지 않습니다.** + +## 방법 +1. 주장 목록을 만든다: "X 를 고쳤다", "원인은 Y", "테스트 N건 통과", "필드 Z 를 추가했다". +2. 각 주장을 **직접 재현**한다 — 코드 `파일:라인` 인용, 테스트 직접 실행, grep 으로 모든 호출처 확인, 필요하면 운영 DB 읽기 전용 조회(메모리 `nas-readonly-db-access`, 비밀번호 출력 금지, sudo 금지). +3. 판정: **확인됨 / 반증됨(증거) / 검증 불가(이유)**. 검증 불가를 확인됨으로 올리지 말 것. +4. 적대적 탐색: 수정된 경로에 대해 경계값·동시성·재기동·부분체결·결과 불명·네트워크 실패·장외/휴장 시나리오를 만들어 실패를 찾는다. + +## 반드시 확인 +- 테스트 실행: `cd backend && DATABASE_URL="postgresql+asyncpg://x:x@localhost/autostock_test" ../.venv/bin/python -m pytest tests/ --ignore=tests/integration -q`, 실호출 없음은 소켓 차단 플러그인 하 통과로만 인정(§4.1). +- 값 반환 스텁·약화된 단언·하드코딩 config 로 통과시키는 테스트. +- CLAUDE.md 규칙(§3 DB, §4 시그니처·`_dumps`·try 밖 가드·`logger.debug` 금지, §5 리스크). + +## 반환 형식 +주장별 판정 표 / 새로 찾은 결함([Critical/High/Medium/Low] 파일:라인, 재현 절차, 확신도) / 마지막 줄 `CRITICAL=n HIGH=n MEDIUM=n LOW=n REFUTED=n`. diff --git a/.claude/agents/scalping-agent.md b/.claude/agents/scalping-agent.md new file mode 100644 index 0000000..6adfcf4 --- /dev/null +++ b/.claude/agents/scalping-agent.md @@ -0,0 +1,37 @@ +--- +name: scalping-agent +model: opus +description: | + 스캘핑(초~수분 보유, 틱/1분봉) 전문 트레이더 에이전트. 현재 인프라에서 스캘핑이 성립하는지를 + 비용·지연·호출 한도·체결 확률로 정량 판정하고, 성립 조건과 필요한 인프라를 제시합니다. + 다음 상황에서 사용하세요: + - 스캘핑/초단타 전략 도입 타당성 검토 + - 틱·호가 데이터(KIS 실시간 H0STCNT0/H0STASP0) 품질과 지연 측정 설계 + - DT 진입·청산 타이밍의 미시구조(스프레드, 호가 잔량, 슬리피지) 개선 +tools: + - Read + - Glob + - Grep + - Bash + - Edit + - Write +--- + +당신은 auto-stock 의 **스캘핑 전문가**입니다. 첫 번째 임무는 **"이 시스템에서 스캘핑이 기대값 양수로 성립하는가"를 숫자로 판정**하는 것이고, 성립하지 않으면 그렇게 말하는 것이 정답입니다. 작업 전 `CLAUDE.md` §1·§3·§7 을 읽으십시오. + +## 현재 인프라 사실 (코드로 재확인할 것) +- DT 스케줄러 30초 사이클(`daytrading/scheduler.py`), 5분봉 신호, 주문은 KIS REST(전역 쓰로틀 + 세마포어). +- 실시간 스트림은 세션당 등록 41건(체결+호가 → 약 20종목), 화면·Redis 표시용이며 **주문 경로에 쓰이지 않는다**. +- 왕복 비용 ≈ 0.21%(수수료 0.015%×2 + 매도세 0.18%) + 시장가 슬리피지(1틱 이상). 모의투자 서버는 체결 모델이 실서버와 다를 수 있다. + +## 판정 방법 +1. 손익분기: 목표 이익폭이 `왕복비용 + 평균 슬리피지×2` 를 넘는지, 그 이익폭이 보유 시간 안에 도달할 확률(1분봉/틱 데이터로 측정). +2. 지연: 신호→주문 전송→체결 확인까지의 실제 지연(로그·코드 경로로 추정, 측정 불가하면 "미측정"으로). +3. 호출 한도: KIS 초당 호출 제한 대비 필요한 조회·주문 수. +4. 결론은 **성립 / 조건부 성립(필요 인프라 목록) / 불성립** 중 하나와 근거 수치. + +## 금지 +- 근거 없는 스캘핑 전략 코드 추가. 실호출 테스트(§4.1). 주문 경로 변경은 daytrading-agent·risk-monitor-agent 와 합의된 설계 없이 하지 말 것. + +## 반환 형식 +판정 / 근거 수치(출처) / 미측정 항목 / 성립에 필요한 변경 목록(비용·위험 포함). diff --git a/.claude/agents/swing-trader-agent.md b/.claude/agents/swing-trader-agent.md new file mode 100644 index 0000000..5f0af2f --- /dev/null +++ b/.claude/agents/swing-trader-agent.md @@ -0,0 +1,44 @@ +--- +name: swing-trader-agent +model: opus +description: | + 스윙 트레이딩(일봉, 수일~수주 보유) 전문 트레이더 에이전트. 평균회귀·모멘텀 돌파·눌림목·BEAR v2·박스권 전략의 + 진입/청산/손절/익절 구조와 실거래 손익을 트레이더 관점에서 진단·개선합니다. + 다음 상황에서 사용하세요: + - 스윙 손익 구조 진단(평균 손실/이익 비, 손익분기 승률, 청산 사유별 기여) + - 손절·트레일링·분할익절·시간손절 설계 검토 + - 국면(BULL/BEAR/SIDEWAYS)별 전략 커버리지와 진입 게이트 과다/과소 + - 실전 진입 게이트와 백테스트 로직의 불일치(§6.2) 점검 +tools: + - Read + - Glob + - Grep + - Bash + - Edit + - Write +--- + +당신은 auto-stock 의 **스윙 트레이더**입니다. 10년 이상 한국 주식 스윙을 운용한 실무자의 관점으로, 코드가 *실제로* 하는 일과 실거래 데이터만을 근거로 판단합니다. 작업 전 `CLAUDE.md` §0·§5·§6·§8 을 반드시 읽으십시오. + +## 담당 파일 +| 파일 | 역할 | +|------|------| +| `backend/app/services/mean_reversion_strategy.py` | 스윙 3전략 + BEAR v2 (백테스트용 `check_buy_signal` 포함) | +| `backend/app/api/endpoints/recommendations.py` | **실전 1차 진입 게이트** `is_mean_reversion_candidate` | +| `backend/app/services/swing/{entry_service,exit_service,position_manager,scheduler,fill_sweep}.py` | 진입·청산 실행 | +| `backend/app/services/range_trading_strategy.py` | 박스권(미배선, OFF) | +| `backend/app/services/adaptive_strategy.py` | 모드 오버레이(§8.3) | +| `backend/app/services/market_analyzer.py` | 국면 게이트 | + +## 판단 원칙 +1. **손익분기 승률을 먼저 계산하라**: `평균손실 / (평균이익 + 평균손실)`. 실측 승률이 그보다 낮으면 진입 신호가 아니라 **청산 비대칭**이 1차 문제다. +2. 운영 실측(2026-04~10-08): 스윙 65건 순손익 **−104,096원**, 승률 69%, 평균 이익 +8,162 / 평균 손실 −23,570 (2.9배) → 손익분기 승률 ≈74%. 트레일링 청산 9건이 손실(−91,850, 10/02 이전 경로). 손절선 대비 −8% 아래 체결 사례(카카오뱅크 id 282). +3. 파라미터 변경 제안은 **§8.4 요건의 walk-forward 백테스트 근거**가 있어야 한다. 근거 없는 값은 "제안(미검증)"으로만 쓴다. +4. 손절 규칙(§5.2): 가격 기준 `max`, 음수 퍼센트 기준 `max`, `-abs()` 부호 가드 유지. 가드는 `try` 밖. +5. 청산은 항상 매수보다 우선(§8.1-11). SIDEWAYS 진입 제안 금지. + +## 운영 DB 조회 +읽기 전용 경로만 사용(메모리 `nas-readonly-db-access`): `ssh -f -N nas-db` 터널 + `~/.config/auto-stock/bt.env`(내용 출력 금지). sudo 금지. + +## 반환 형식 +사실(쿼리 결과·`파일:라인`) / 추정(명시) / 결함(코드 버그, 즉시 수정 대상) / 전략 개선(백테스트 필요) / 우선순위. diff --git a/.claude/hooks/tests/test_hooks.py b/.claude/hooks/tests/test_hooks.py index 5054840..297cffa 100644 --- a/.claude/hooks/tests/test_hooks.py +++ b/.claude/hooks/tests/test_hooks.py @@ -336,15 +336,19 @@ def test_scripts_syntax(sh): # ── 운영 스모크: 인증 필수 엔드포인트는 401 이 정상, 키 없이 200 은 노출 회귀 (2026-10-02 H-1) ── -def _smoke_against(status_by_path): +def _smoke_against(status_by_path, health=None): """로컬 스텁 HTTP 서버를 PROD_BASE_URL 로 두고 prod_smoke.sh 실행 (외부 네트워크 0).""" import http.server import threading class H(http.server.BaseHTTPRequestHandler): def do_GET(self): - code = status_by_path.get(self.path, 404) - body = b'{"ok": 1}' if code == 200 else b'{"detail": "x"}' + if self.path == "/health" and health is not None: + import json as _j + code, body = 200, _j.dumps(health).encode() + else: + code = status_by_path.get(self.path, 404) + body = b'{"ok": 1}' if code == 200 else b'{"detail": "x"}' self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) @@ -379,6 +383,31 @@ def test_prod_smoke_fails_on_unauthenticated_leak(): assert p.returncode == 1 and "LEAK" in p.stdout +_OK_PATHS = {"/api/v1/market/indices": 200, **{a: 401 for a in _AUTH_PATHS}} +_SCHED_OK = {"swing": {"alive": True, "stale": False}} + + +def test_prod_smoke_degraded_reasons_only_warn(): + h = {"status": "degraded", "db": "ok", "redis": "ok", "schedulers": _SCHED_OK, + "db_worker": {"alive": True}, "degraded_reasons": ["krx_data_incomplete", "dlq_manual"]} + p = _smoke_against(_OK_PATHS, h) + assert p.returncode == 0, p.stdout + p.stderr + assert "WARN HEALTH" in p.stdout and "krx_data_incomplete" in p.stdout + + +def test_prod_smoke_fails_on_scheduler_dead_even_if_degraded(): + h = {"status": "degraded", "db": "ok", "redis": "ok", + "schedulers": {"swing": {"alive": False}}, "degraded_reasons": ["x"]} + p = _smoke_against(_OK_PATHS, h) + assert p.returncode == 1 and "FAIL HEALTH" in p.stdout + + +def test_prod_smoke_fails_on_db_error(): + h = {"status": "degraded", "db": "error", "redis": "ok", "schedulers": _SCHED_OK, "degraded_reasons": []} + p = _smoke_against(_OK_PATHS, h) + assert p.returncode == 1 and "db error" in p.stdout + + def test_prod_smoke_fails_when_down(): p = _smoke_against({"/api/v1/market/indices": 502, **{a: 502 for a in _AUTH_PATHS}}) assert p.returncode == 1 diff --git a/.claude/scripts/prod_smoke.sh b/.claude/scripts/prod_smoke.sh index 489ef97..a5c83a6 100755 --- a/.claude/scripts/prod_smoke.sh +++ b/.claude/scripts/prod_smoke.sh @@ -39,5 +39,38 @@ probe() { # $1=kind(PUBLIC|AUTH) $2=endpoint } for ep in "${PUBLIC_ENDPOINTS[@]}"; do probe PUBLIC "$ep"; done for ep in "${AUTH_ENDPOINTS[@]}"; do probe AUTH "$ep"; done +# /health 심층 확인 — 컨테이너 healthcheck 는 HTTP 200 만 보므로 스케줄러·db_worker 사망을 못 잡는다. +# 비JSON(프록시 미노출 추정)은 WARN. 배포 회귀 신호(스케줄러 사망·stale, db_worker 사망, db/redis error)만 FAIL. +# status=degraded 와 degraded_reasons(KRX 미수집·MDD blind·국면 차단·KIS 스트림·DLQ manual 등 운영성 사유)는 +# 배포와 무관할 수 있으므로 WARN 으로만 출력한다. +hbody="$(curl -s --max-time 15 "$BASE/health" 2>/dev/null || true)" +if command -v python3 >/dev/null 2>&1; then + hres="$(printf '%s' "$hbody" | python3 -c ' +import json,sys +try: h=json.load(sys.stdin) +except Exception: print("NOJSON"); sys.exit(0) +if not isinstance(h,dict) or "status" not in h: print("NOJSON"); sys.exit(0) +p=[]; w=[] +if h.get("status") not in ("healthy","degraded"): p.append("status="+str(h.get("status"))) +for k in ("db","redis"): + if k in h and h[k]!="ok": p.append(k+" "+str(h[k])) +for n,v in (h.get("schedulers") or {}).items(): + if not v.get("alive"): p.append("scheduler "+n+" 사망") + elif v.get("stale"): p.append("scheduler "+n+" stale") +if "alive" in (h.get("db_worker") or {}) and not h["db_worker"]["alive"]: p.append("db_worker 사망") +rs=h.get("degraded_reasons") or [] +if h.get("status")=="degraded" or rs: w.append("degraded: "+",".join(str(r) for r in rs)) +print(("BAD "+"; ".join(p)) if p else "OK") +if w: print("WARNDEG "+"; ".join(w)) +')" + case "$hres" in + NOJSON) echo "WARN HEALTH $BASE/health JSON 아님(프록시 미노출 추정) — 내부에서 scripts/health_watch.py 로 확인" ;; + BAD*) echo "FAIL HEALTH ${hres%%$'\n'*}"; fail=1 ;; + *) echo "OK HEALTH $BASE/health 회귀 신호 없음(schedulers alive, db/redis ok)" ;; + esac + case "$hres" in + *WARNDEG*) echo "WARN HEALTH 운영성 degraded(배포 회귀 아님 가능) — ${hres##*WARNDEG }" ;; + esac +fi [ "$fail" = 0 ] && echo "== 스모크 통과 ==" || echo "== 스모크 실패 — Jenkins 로그/컨테이너 상태 확인 (LEAK 이면 인증 회귀) ==" exit "$fail" diff --git a/backend/app/api/endpoints/analysis.py b/backend/app/api/endpoints/analysis.py index 2ebbb05..ff81bab 100644 --- a/backend/app/api/endpoints/analysis.py +++ b/backend/app/api/endpoints/analysis.py @@ -21,6 +21,7 @@ from app.models.models import StrategyOptimizationLog, TradeJournal from app.core.timeutil import now_kst from app.services.trade_analyzer import ( analyze_performance, + analyze_performance_v2, build_insights, create_journal_record, generate_journal_for_trade, @@ -53,6 +54,19 @@ async def get_performance( return {"error": "성과 분석 실패 — 서버 로그 확인", "summary": {}, "total_trades": 0} +@router.get("/performance_v2") +async def get_performance_v2( + days: int = Query(default=30, ge=1, le=365, description="분석 기간 (일)"), + db: AsyncSession = Depends(get_db), +) -> Dict[str, Any]: + """보정판 성과: manual/external 제외 시스템 성과, 포지션 단위 승률, 0 채움 Sharpe.""" + try: + return await analyze_performance_v2(db, days=days) + except Exception as e: + logger.error(f"performance_v2 분석 실패: {e}", exc_info=True) + return {"error": "성과 분석 실패 — 서버 로그 확인", "summary": {}, "total_trades": 0} + + # ───────────────────────────────────────────────────────────────────────────── # GET /insights # ───────────────────────────────────────────────────────────────────────────── @@ -64,7 +78,7 @@ async def get_insights( ) -> Dict[str, Any]: """전략 인사이트 및 개선 제안 반환.""" try: - perf = await analyze_performance(db, days=days) + perf = await analyze_performance_v2(db, days=days) insights = build_insights(perf) last_opt_date = await get_last_optimization_date(db) return { @@ -91,7 +105,7 @@ async def _generate_journals_task(days: int = 7) -> Dict[str, Any]: if not trades: return {"created": 0, "message": "생성할 미처리 거래 없음"} - perf = await analyze_performance(db, days=30) + perf = await analyze_performance_v2(db, days=30) created = 0 for trade in trades: try: diff --git a/backend/app/api/endpoints/day_trading.py b/backend/app/api/endpoints/day_trading.py index 4a41fac..449bb46 100644 --- a/backend/app/api/endpoints/day_trading.py +++ b/backend/app/api/endpoints/day_trading.py @@ -3,23 +3,20 @@ 라우터 prefix: /api/daytrading -엔드포인트: +엔드포인트 (전부 실제 엔진 services/daytrading 상태 기준): GET /watchlist — 감시 종목 목록 조회 POST /watchlist — 감시 종목 추가/교체 - GET /signals — 현재 진입 신호 조회 - POST /execute — 신호 기반 데이트레이딩 실행 + GET /signals — 현재 진입 신호 조회 (읽기 전용, 참고용) + POST /execute — 단일 종목 신호 확인 (dry_run 전용, 실매수 410) GET /positions — 활성 데이트레이딩 포지션 조회 - POST /close-all — 전체 포지션 강제 청산 (EOD 수동) - POST /cycle — 1분 주기 사이클 수동 실행 (디버그용) + POST /close-all — 전체 포지션 강제 청산 (비어 있으면 전일·고아 잔고 청산) + POST /cycle — 1 사이클 수동 실행 (디버그용) -상태 (인메모리, 일일 초기화): - _watchlist — 감시 종목 코드+이름 딕셔너리 - _positions — 활성 포지션 {stock_code: {...}} - _orb_cache — 종목별 ORB 데이터 - _daily_pnl — 오늘 실현 손익 - _last_date — 마지막 초기화 날짜 (날짜 바뀌면 자동 리셋) +[2026-10-09] 레거시 매매 사이클(_check_exits/_scan_signals/_execute_entry/ +_execute_eod_flat/restore_positions_from_db)은 제거했다. 아래 모듈 전역(_watchlist, +_positions, _orb_cache …)은 엔진과 공유되지 않는 잔재이며 매매 판정에 쓰지 않는다. """ -from fastapi import APIRouter, HTTPException, Depends, Query +from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel from typing import List, Optional, Dict, Any from datetime import datetime, date, time as dtime @@ -31,26 +28,18 @@ from app.services.korea_investment_api import korea_investment_api from app.services.day_trading_strategy import ( MinuteCandle, OpeningRange, DayTradeSignal, parse_minute_candles, aggregate_to_5min, - calculate_vwap, calculate_opening_range, + calculate_vwap, check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal, - check_day_trade_exit, is_time_stop_exit_reason, calculate_day_trade_position_size, is_daily_loss_exceeded, - ORB_END, ENTRY_CUTOFF, EOD_FLAT, MAX_POSITIONS, + ENTRY_CUTOFF, MAX_POSITIONS, ) from app.core.market_schedule import ( is_market_open, is_opening_range_period, is_day_trade_entry_window, is_eod_flat_time, ) from app.core.config import settings -from app.core.json_helpers import _dumps -from app.services.websocket_manager import websocket_manager from app.services.loss_tracker import loss_tracker -from app.core.database import get_db, AsyncSessionLocal -from app.models.models import ActiveTrade from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -# MCP risk assessment — 모듈 최상단에서 import (순환 참조 시 별도 util 모듈 분리 고려) -from app.api.endpoints.mcp import request_mcp_risk_assessment from app.core.security import require_api_key router = APIRouter() @@ -146,19 +135,44 @@ class ExecuteRequest(BaseModel): # ── 내부 헬퍼 ───────────────────────────────────────────────────────────────── -async def _fetch_candles(stock_code: str) -> List[MinuteCandle]: - """당일 전체 분봉 데이터 조회 및 파싱""" - raw = await korea_investment_api.get_minute_chart_full_day(stock_code) - return parse_minute_candles(raw) - - async def _get_current_price(stock_code: str) -> float: - """현재가 조회""" + """현재가 조회 (실패 시 0.0 — 기존 계약 유지. 신규 호출부는 _v2 를 쓸 것)""" + price = await _get_current_price_v2(stock_code) + return price if price is not None else 0.0 + + +# [R7 2026-10-10] 현재가 조회 실패 경고 스로틀 (종목별 마지막 경고 시각, monotonic 초) +_PRICE_FAIL_LOG_INTERVAL_SEC: float = 60.0 +_price_fail_logged_at: Dict[str, float] = {} + + +def _warn_price_fail_throttled(stock_code: str, detail: str) -> None: + import time as _time + now = _time.monotonic() + last = _price_fail_logged_at.get(stock_code) + if last is not None and now - last < _PRICE_FAIL_LOG_INTERVAL_SEC: + return + _price_fail_logged_at[stock_code] = now + logger.warning(f"[DT-API] {stock_code} 현재가 조회 실패 — {detail} " + f"(종목당 {_PRICE_FAIL_LOG_INTERVAL_SEC:.0f}초에 1회 기록)") + + +async def _get_current_price_v2(stock_code: str) -> Optional[float]: + """[R7 2026-10-10] 현재가 조회 — 실패·0 이하·비유한이면 None. + + 구버전은 실패를 0.0 으로 삼켜 /positions 가 손익률 -100% 를 응답했다. + """ + import math try: result = await korea_investment_api.get_current_price(stock_code) - return float(result.get("output", {}).get("stck_prpr", 0) or 0) - except Exception: - return 0.0 + price = float(((result or {}).get("output") or {}).get("stck_prpr", 0) or 0) + except Exception as e: + _warn_price_fail_throttled(stock_code, f"{type(e).__name__}: {e}") + return None + if not math.isfinite(price) or price <= 0: + _warn_price_fail_throttled(stock_code, f"유효하지 않은 가격 응답({price})") + return None + return price async def _get_portfolio_summary(force: bool = False) -> Dict[str, float]: @@ -196,47 +210,6 @@ async def _get_portfolio_summary(force: bool = False) -> Dict[str, float]: return _portfolio_summary_cache or {"total_assets": 0.0, "available_cash": 0.0} -async def _place_buy_order( - stock_code: str, - quantity: int, - price: int, -) -> Optional[str]: - """매수 주문 실행, 주문번호 반환 (실패 시 None)""" - try: - result = await korea_investment_api.place_order( - stock_code=stock_code, - order_type="BUY", - quantity=quantity, - price=price, - ) - if result.get("rt_cd") == "0": - return result.get("output", {}).get("ODNO", "") - logger.warning(f"매수 주문 실패 [{stock_code}]: {result.get('msg1')}") - return None - except Exception as e: - logger.error(f"매수 주문 오류 [{stock_code}]: {e}") - return None - - -async def _place_sell_order( - stock_code: str, - quantity: int, - price: int = 0, # 0 = 시장가 (None으로 변환) -) -> bool: - """매도 주문 실행""" - try: - result = await korea_investment_api.place_order( - stock_code=stock_code, - order_type="SELL", - quantity=quantity, - price=price if price > 0 else None, # 0이면 None → 시장가(01) - ) - return result.get("rt_cd") == "0" - except Exception as e: - logger.error(f"매도 주문 오류 [{stock_code}]: {e}") - return False - - async def _record_trade( db: AsyncSession, stock_code: str, @@ -279,513 +252,118 @@ async def _record_trade( logger.error(f"TradeHistory 기록 실패: {e}") -# ── 핵심 사이클 로직 ────────────────────────────────────────────────────────── +# ── 레거시 사이클 (제거됨) ──────────────────────────────────────────────────── async def run_day_trading_cycle() -> Dict[str, Any]: - """1분 주기 데이트레이딩 사이클 + """[DEPRECATED 2026-10-09] 레거시 1분 사이클 — 매매하지 않는 정지 스텁. - 흐름: - 1. 장 미개장 → 스킵 - 2. 일일 상태 초기화 체크 - 3. Opening Range 수집 (09:00~09:30) - 4. 활성 포지션 청산 체크 - 5. 신규 진입 신호 체크 (09:30~15:00) - 6. 결과 반환 + 이 모듈의 레거시 상태(_positions/_orb_cache/_closed_today)는 실제 엔진 + (services/daytrading, day_trading_state)과 공유되지 않는 별개 dict 다. 이 경로가 + 다시 돌면 엔진과 같은 종목을 이중 매수·이중 매도한다. 호출부는 deprecated + `schedulers.day_trade_scheduler`(main.py 미배선)의 지연 import 1곳뿐이라 함수명은 + 남기고(ImportError 방지, §4 시그니처 보존) 본문은 매매 없이 반환한다. + 청산·진입·EOD·ORB 는 전부 `services/daytrading/scheduler.daytrading_scheduler` 가 한다. """ - _reset_if_new_day() - now_time = _kst_time() - result: Dict[str, Any] = { + logger.error( + "[DayTrade] 레거시 run_day_trading_cycle 호출됨 — 매매하지 않음. " + "엔진(services/daytrading/scheduler.daytrading_scheduler)만 사용할 것" + ) + return { "time": _kst_now().strftime("%H:%M:%S"), - "phase": "", + "phase": "deprecated", "exits": [], "entries": [], "signals": [], } - if not is_market_open(): - result["phase"] = "market_closed" - return result - # ── 1. EOD 강제 청산 ── - if is_eod_flat_time(): - result["phase"] = "eod_flat" - await _execute_eod_flat() - return result +# ── 엔진 기준 읽기 전용 신호 평가 (v2) ─────────────────────────────────────── - # ── 2. ORB 수집 단계 (09:00~09:30) ── - if is_opening_range_period(): - result["phase"] = "opening_range" - await _collect_opening_range() - result["orb_count"] = len(_orb_cache) - return result - - # ── 3. 매매 가능 구간 ── - in_entry_window = is_day_trade_entry_window() # 09:30~15:00 - - # 포지션이 있으면 입장 창 밖(15:00~15:20)에서도 청산 체크 수행 - if not in_entry_window and not _positions: - result["phase"] = "outside_window" - return result - - result["phase"] = "active" - - # 사이클 단위 분봉 캐시 — 청산 체크와 신규 진입에서 동일 종목 중복 fetch 방지 - _cycle_candle_cache: Dict[str, List[MinuteCandle]] = {} - - # ── 4. 활성 포지션 청산 체크 ── - exits = await _check_exits(candle_cache=_cycle_candle_cache) - result["exits"] = exits - - # ── 5. 신규 진입 신호 체크 (진입 창 안에서만) ── - if not in_entry_window: - result["phase"] = "exit_only" - return result - - # MDD -10% 시 단기 전략(데이트레이딩 포함) 중단 체크 (CLAUDE.md 섹션 5.3) - from app.services.mdd_manager import mdd_manager - if not await mdd_manager.is_short_term_strategy_enabled(): - logger.info("⏸️ [DayTrade] MDD -10% 이상 — 단기 전략 중단 (신규 진입 불가)") - result["phase"] = "active" - return result - - if not _is_new_entry_allowed(): - # 14:30(DAY_TRADE_ENTRY_CUTOFF) 이후 — 신규 진입 마감 (CLAUDE.md 7.3), 청산은 위에서 이미 처리됨 - result["phase"] = "entry_cutoff" - return result - - if len(_positions) < MAX_POSITIONS and settings.AUTO_TRADE_ENABLED: - portfolio = await _get_portfolio_summary() - total_assets = portfolio["total_assets"] - available_cash = portfolio["available_cash"] - global _last_total_assets - if total_assets > 0: - _last_total_assets = total_assets - else: - # 총자산 조회 실패 시 신규 진입 차단 — 손실 한도 체크 우회 방지 - logger.warning("⚠️ 총자산 조회 실패(0원) → 신규 진입 차단") - return result - - if not is_daily_loss_exceeded(total_assets, _daily_pnl): - current_day_trade_amount = sum( - p["entry_price"] * p["quantity"] for p in _positions.values() - ) - signals = await _scan_signals( - total_assets=total_assets, - available_cash=available_cash, - current_day_trade_amount=current_day_trade_amount, - candle_cache=_cycle_candle_cache, - ) - result["signals"] = [ - { - "stock_code": s["signal"].stock_code, - "strategy": s["signal"].strategy, - "entry_price": s["signal"].entry_price, - "stop_loss": s["signal"].stop_loss, - "target_price": s["signal"].target_price, - "reason": s["signal"].reason, - "quantity": s["quantity"], - } - for s in signals - ] - - for s in signals: - if len(_positions) >= MAX_POSITIONS: - break - entry_result = await _execute_entry(s["signal"], s["quantity"]) - if entry_result: - result["entries"].append(entry_result) - else: - logger.warning( - f"⚠️ 일일 손실 한도 도달 ({_daily_pnl:,.0f}원) → 신규 진입 중단" - ) - - return result - - -async def _collect_opening_range() -> None: - """Opening Range 집계 (09:00~09:30)""" - for code in list(_watchlist.keys()): - if code in _orb_cache: - continue - try: - candles = await _fetch_candles(code) - orb = calculate_opening_range(candles) - if orb: - _orb_cache[code] = orb - logger.info( - f"📊 ORB 수집 [{code}]: " - f"H={orb.high:.0f}, L={orb.low:.0f}, " - f"분당거래량={orb.avg_vol_per_min:.0f}" - ) - except Exception as e: - logger.warning(f"ORB 수집 실패 [{code}]: {e}") - await asyncio.sleep(_API_CALL_DELAY) - - -async def _check_exits( - candle_cache: Optional[Dict[str, List[MinuteCandle]]] = None, -) -> List[Dict[str, Any]]: - """활성 포지션 청산 조건 체크 및 실행 - - Args: - candle_cache: 사이클 단위 분봉 캐시 {stock_code: candles}. None이면 직접 fetch. - """ - global _daily_pnl, _portfolio_cache_dirty - exits = [] - now_time = _kst_time() - - for code, pos in list(_positions.items()): - try: - current_price = await _get_current_price(code) - if current_price <= 0: - continue - - # 최고가 갱신 - if current_price > pos["highest_price"]: - _positions[code]["highest_price"] = current_price - - if candle_cache is not None and code in candle_cache: - candles = candle_cache[code] - else: - candles = await _fetch_candles(code) - if candle_cache is not None: - candle_cache[code] = candles - candles_5m = aggregate_to_5min(candles) - - exit_signal = check_day_trade_exit( - entry_price=pos["entry_price"], - current_price=current_price, - stop_loss=pos["stop_loss"], - target_price=pos["target_price"], - highest_since_entry=pos["highest_price"], - candles_5min=candles_5m, - current_time=now_time, - ) - - if exit_signal: - quantity = pos["quantity"] - ok = await _place_sell_order(code, quantity, price=0) # 시장가 - if ok: - pnl = (current_price - pos["entry_price"]) * quantity - _portfolio_cache_dirty = True # 매도 체결 → 잔고 변경됨 - async with _trade_lock: - _daily_pnl += pnl - _positions.pop(code, None) - _closed_today.add(code) # 당일 재진입 차단 - - # db_worker 큐 경유 — CLAUDE.md 3.2 준수 - from app.services.db_worker import enqueue_close_active_trade - await _record_trade( - None, code, pos["stock_name"], "SELL", - current_price, quantity, exit_signal.reason, pnl, - ) - await enqueue_close_active_trade(code, strategy_name="day_trade") - - # loss_tracker 기록 — CLAUDE.md 7.3 규칙 준수 - # [2026-10-01] 시간손절은 연속손실 집계 제외(중립) — 엔진 경로(exit_service)와 동일 - if not is_time_stop_exit_reason(exit_signal.reason): - _profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0 - await loss_tracker.record_trade_result_safe(code, _profit_rate < 0) - await loss_tracker.save_to_db(db=None, stock_code=code) - - exits.append({ - "stock_code": code, - "reason": exit_signal.reason, - "pnl": round(pnl, 0), - "price": current_price, - }) - logger.info( - f"✅ 데이트레이딩 청산 [{code}]: " - f"{exit_signal.reason}, PNL={pnl:+,.0f}원" - ) - await websocket_manager.broadcast(_dumps({ - "type": "day_trade_exit", - "stock_code": code, - "reason": exit_signal.reason, - "pnl": pnl, - })) - except Exception as e: - logger.error(f"청산 체크 오류 [{code}]: {e}") - await asyncio.sleep(_API_CALL_DELAY) - - return exits - - -async def _scan_signals( +async def _evaluate_engine_signal_v2( + stock_code: str, + stock_name: str, total_assets: float, available_cash: float, current_day_trade_amount: float, - candle_cache: Optional[Dict[str, List[MinuteCandle]]] = None, -) -> List[Dict[str, Any]]: - """감시 종목에서 진입 신호 스캔 +) -> Dict[str, Any]: + """[2026-10-09] 엔진(`entry_service._try_enter`)과 같은 입력·순서로 신호만 판정한다. - Args: - candle_cache: 사이클 단위 분봉 캐시 {stock_code: candles}. None이면 직접 fetch. + 주문·MCP 평가·슬롯 예약·DB 접근 없음(읽기 전용). 레거시 `_scan_signals` 는 이 모듈의 + 빈 `_orb_cache`/`_positions`/`_closed_today` 로 판정해 ORB 신호가 영영 안 나오고 + 보유·당일청산·DT 예산 차감이 빠져 실제 엔진과 어긋났다. + + `_try_enter` 와 동일하게 맞춘 것: loss_tracker 밴 / VI 쿨다운 / 엔진 분봉 캐시 + (`_get_candles_cached`, 전일 워밍업 포함) / 당일 봉 필터(`filter_today_candles`) / + 5분봉 ≥5 / VWAP=당일 봉 / ORB=`day_trading_state.get_orb` / 현재가=당일 마지막 1분봉 / + ORB 입력 `DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED` 분기 / ORB→VWAP→RSI_BB 순서 / + 사이징 = position_size → confidence 배율 → `_clamp_day_trade_budget` 재클램프. + `_try_enter` 를 바꾸면 이 함수도 같이 바꿔야 한다(테스트가 판정 입력을 고정). + + Returns: + {"signal": DayTradeSignal | None, "quantity": int, "skip": str | None} """ - results = [] - for code, name in list(_watchlist.items()): - if code in _positions: - continue - if code in _closed_today: # 당일 청산 종목 재진입 차단 - continue - try: - if candle_cache is not None and code in candle_cache: - candles_1m = candle_cache[code] - else: - candles_1m = await _fetch_candles(code) - if candle_cache is not None: - candle_cache[code] = candles_1m - if not candles_1m: - continue - candles_5m = aggregate_to_5min(candles_1m) - current_price = candles_1m[-1].close if candles_1m else 0 - if current_price <= settings.MIN_PRICE_KRW: - continue + _es, _st = _engine() + if await loss_tracker.is_banned_safe(stock_code): + return {"signal": None, "quantity": 0, "skip": "loss_tracker ban"} + from app.services.market_playbook import is_vi_active + if is_vi_active(stock_code): + return {"signal": None, "quantity": 0, "skip": "VI 쿨다운"} - vwap = calculate_vwap(candles_1m) - orb = _orb_cache.get(code) + candles = await _es._get_candles_cached(stock_code) + if not candles: + return {"signal": None, "quantity": 0, "skip": "분봉 없음"} + candles_today = _es.filter_today_candles(candles) + if not candles_today: + return {"signal": None, "quantity": 0, "skip": "당일 분봉 없음"} + candles_5m = aggregate_to_5min(candles) + if len(candles_5m) < 5: + return {"signal": None, "quantity": 0, "skip": "5분봉 부족"} - signal: Optional[DayTradeSignal] = None + vwap = calculate_vwap(candles_today) + orb = _st.get_orb(stock_code) + current_price = float(candles_today[-1].close) + if current_price <= 0: + return {"signal": None, "quantity": 0, "skip": "현재가 없음"} - # ORB 우선 - if orb: - signal = check_orb_signal(code, name, candles_1m, orb, current_price) + signal: Optional[DayTradeSignal] = None + if orb: + _orb_input = candles if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED else candles_today + signal = check_orb_signal(stock_code, stock_name, _orb_input, orb, current_price) + if not signal and vwap: + signal = check_vwap_bounce_signal(stock_code, stock_name, candles_5m, vwap, current_price) + if not signal: + signal = check_rsi_bb_signal(stock_code, stock_name, candles_5m, current_price) + if not signal: + return {"signal": None, "quantity": 0, "skip": None} - # VWAP 반등 - if signal is None and vwap: - signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price) - - # RSI+BB - if signal is None: - signal = check_rsi_bb_signal(code, name, candles_5m, current_price) - - if signal: - position_size = calculate_day_trade_position_size( - total_assets, available_cash, current_day_trade_amount - ) - if position_size < signal.entry_price: - continue # 최소 1주도 살 수 없으면 스킵 - - # [항목10 2026-09-27] max(1, ...) 제거 — 예산 0/음수에서도 1주를 - # 매수해 사이징 한도(일일 DT 예산·현금)를 무시하던 문제. - quantity = int(position_size // signal.entry_price) if signal.entry_price > 0 else 0 - if quantity <= 0: - logger.info( - f"⛔ 데이트레이딩 진입 스킵 [{code}]: 예산 부족 " - f"(예산 {position_size:,.0f}원 / 단가 {signal.entry_price:,.0f}원 → 0주)" - ) - continue - results.append({"signal": signal, "quantity": quantity}) - logger.info( - f"🎯 데이트레이딩 신호 [{code}] {signal.strategy}: " - f"{signal.reason}, 수량={quantity}" - ) - - except Exception as e: - logger.warning(f"신호 스캔 오류 [{code}]: {e}") - await asyncio.sleep(_API_CALL_DELAY) - - # confidence 내림차순 정렬 - results.sort(key=lambda x: x["signal"].confidence, reverse=True) - return results[:MAX_POSITIONS - len(_positions)] + budget = calculate_day_trade_position_size( + total_assets=total_assets, + available_cash=available_cash, + current_day_trade_amount=current_day_trade_amount, + ) + confidence_scale = min(1.2, signal.confidence / 0.7) + budget = _es._clamp_day_trade_budget( + budget * confidence_scale, + total_assets=total_assets, + available_cash=available_cash, + current_day_trade_amount=current_day_trade_amount, + ) + quantity = int(budget // current_price) if current_price > 0 else 0 + return { + "signal": signal, + "quantity": quantity, + "skip": None if quantity > 0 else f"예산 부족(예산 {budget:,.0f}원 / 단가 {current_price:,.0f}원)", + "budget": budget, + } -async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict]: - """진입 주문 실행 및 포지션 등록. - - 주문 직전 MCP RISK_ASSESSMENT를 호출해 approve 시에만 매수한다 (이슈 4). - MCP 장애/타임아웃은 fail-open(approve=True)으로 처리해 매매를 차단하지 않는다. - """ - async with _trade_lock: - if len(_positions) >= MAX_POSITIONS: - return None - if signal.stock_code in _positions: - return None - if signal.stock_code in _closed_today: # 당일 청산 종목 재진입 최종 방어 - logger.info(f"⛔ 당일 청산 종목 재진입 차단: {signal.stock_code}") - return None - - # ── MCP RISK_ASSESSMENT (공통 함수, 이슈 4 — day_trading 측) ──────────── - _mcp_trade_plan = { - "action": "BUY", - "strategy": signal.strategy, - "stock_code": signal.stock_code, - "stock_name": signal.stock_name, - "entry_price": signal.entry_price, - "stop_loss": signal.stop_loss, - "target_price": signal.target_price, - "quantity": quantity, - "reason": signal.reason, - } - _mcp_signals = { - "strategy": signal.strategy, - "confidence": signal.confidence, - "reason": signal.reason, - } - _approve, _mcp_reason = await request_mcp_risk_assessment( - _mcp_trade_plan, {}, _mcp_signals - ) - if not _approve: - logger.warning( - f"🛑 MCP RISK_ASSESSMENT DENY [{signal.stock_code}] " - f"→ 주문 스킵. 사유: {_mcp_reason}" - ) - return None - if _mcp_reason: - logger.info( - f"✅ MCP RISK_ASSESSMENT APPROVE [{signal.stock_code}]. 참고: {_mcp_reason}" - ) - # ───────────────────────────────────────────────────────────────────── - - order_no = await _place_buy_order( - signal.stock_code, quantity, int(signal.entry_price) - ) - if not order_no: - return None - - global _portfolio_cache_dirty - _portfolio_cache_dirty = True # 매수 체결 → 잔고 변경됨 - - _positions[signal.stock_code] = { - "stock_name": signal.stock_name, - "strategy": signal.strategy, - "entry_price": signal.entry_price, - "stop_loss": signal.stop_loss, - "target_price": signal.target_price, - "quantity": quantity, - "highest_price": signal.entry_price, - "entry_time": _kst_now().strftime("%H:%M:%S"), - "reason": signal.reason, - } - - # db_worker 큐 경유 — CLAUDE.md 3.2 준수 - from app.services.db_worker import enqueue_active_trade_upsert - await _record_trade( - None, signal.stock_code, signal.stock_name, "BUY", - signal.entry_price, quantity, signal.reason, - order_price=float(int(signal.entry_price)), # 주문가 = 지정가 - ) - # ActiveTrade 저장 — 재시작 시 포지션 복구를 위해 - await enqueue_active_trade_upsert({ - "stock_code": signal.stock_code, - "stock_name": signal.stock_name, - "quantity": quantity, - "entry_price": signal.entry_price, - "stop_loss": signal.stop_loss, - "take_profit": signal.target_price, - "highest_since_entry": signal.entry_price, - "strategy_name": "day_trade", - "entry_reason": signal.strategy, - "status": "active", - }) - - logger.info( - f"🛒 데이트레이딩 진입 [{signal.stock_code}] " - f"{signal.strategy}: {signal.entry_price:.0f}원 × {quantity}주" - ) - await websocket_manager.broadcast(_dumps({ - "type": "day_trade_entry", - "stock_code": signal.stock_code, - "strategy": signal.strategy, - "entry_price": signal.entry_price, - "quantity": quantity, - })) - return { - "stock_code": signal.stock_code, - "strategy": signal.strategy, - "entry_price": signal.entry_price, - "quantity": quantity, - "reason": signal.reason, - } - - -async def _execute_eod_flat() -> None: - """EOD 전체 포지션 강제 청산""" - global _daily_pnl, _portfolio_cache_dirty - for code, pos in list(_positions.items()): - try: - current_price = await _get_current_price(code) - ok = await _place_sell_order(code, pos["quantity"], price=0) - if ok: - pnl = (current_price - pos["entry_price"]) * pos["quantity"] - _portfolio_cache_dirty = True # EOD 청산 → 잔고 변경됨 - async with _trade_lock: - _daily_pnl += pnl - _positions.pop(code, None) - _closed_today.add(code) # 당일 재진입 차단 - logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원") - - # db_worker 큐 경유 — CLAUDE.md 3.2 준수 - from app.services.db_worker import enqueue_close_active_trade - await _record_trade( - None, code, pos["stock_name"], "SELL", - current_price, pos["quantity"], "EOD_flat", pnl, - ) - await enqueue_close_active_trade(code, strategy_name="day_trade") - - # loss_tracker 기록 — CLAUDE.md 7.3 규칙 준수 - _profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0 - await loss_tracker.record_trade_result_safe(code, _profit_rate < 0) - await loss_tracker.save_to_db(db=None, stock_code=code) - - await websocket_manager.broadcast(_dumps({ - "type": "day_trade_exit", - "stock_code": code, - "reason": "EOD_flat", - "pnl": pnl, - })) - except Exception as e: - logger.error(f"EOD 청산 오류 [{code}]: {e}") - await asyncio.sleep(_API_CALL_DELAY) - - -async def restore_positions_from_db() -> None: - """앱 재시작 후 오늘 데이트레이딩 포지션 DB 복구. - - strategy_name='day_trade', status='active'인 당일 ActiveTrade 레코드로 - 인메모리 _positions를 재구성한다. - - created_at은 UTC naive datetime으로 저장되므로 날짜 비교는 UTC 기준으로 수행. - 한국 장중(09:00~15:30 KST = 00:00~06:30 UTC)은 UTC/KST 날짜가 항상 일치하므로 - UTC 기준으로 비교해도 동일한 결과를 얻는다. - """ - if _positions: - return # 이미 포지션 있으면 스킵 - from datetime import timezone - today_utc = datetime.now(timezone.utc).date() - try: - async with AsyncSessionLocal() as db: - result = await db.execute( - select(ActiveTrade).where( - ActiveTrade.strategy_name == "day_trade", - ActiveTrade.status == "active", - ) - ) - rows = result.scalars().all() - for row in rows: - if row.created_at and row.created_at.date() != today_utc: - continue - entry_price = float(row.entry_price) - if row.created_at: - created_kst = row.created_at.replace(tzinfo=pytz.utc).astimezone(KST) - entry_time_str = created_kst.strftime("%H:%M:%S") - else: - entry_time_str = "??:??:??" - _positions[row.stock_code] = { - "stock_name": row.stock_name or row.stock_code, - "strategy": row.entry_reason or "unknown", - "entry_price": entry_price, - "stop_loss": float(row.stop_loss) if row.stop_loss else entry_price * 0.97, - "target_price": float(row.take_profit) if row.take_profit else entry_price * 1.03, - "quantity": row.quantity, - "highest_price": float(row.highest_since_entry or row.entry_price), - "entry_time": entry_time_str, - "reason": row.entry_reason or "", - } - if _positions: - logger.info(f"📋 데이트레이딩 포지션 복구: {list(_positions.keys())}") - except Exception as e: - logger.error(f"데이트레이딩 포지션 복구 실패: {e}") +def _engine_dt_amount() -> float: + """엔진 인메모리 DT 투입액(진입가×수량) — 엔진 `_try_enter` 와 같은 정의.""" + _, _st = _engine() + return float(sum( + float(p.get("entry_price", 0) or 0) * int(p.get("quantity", 0) or 0) + for p in _st.positions.values() + )) # ── API 엔드포인트 ──────────────────────────────────────────────────────────── @@ -836,25 +414,66 @@ async def set_watchlist(req: WatchlistRequest): @router.get("/signals") async def get_current_signals(): - """현재 진입 가능 신호 조회 (주문 미실행)""" + """현재 진입 가능 신호 조회 (주문 미실행, 실제 엔진 상태 기준). + + [2026-10-09] 레거시 모듈 상태(항상 비어 있음) 대신 엔진의 감시종목·보유·당일청산·ORB· + DT 투입액으로 `_evaluate_engine_signal_v2` 를 돌린다. 참고용(advisory)이다 — MDD· + 적응형·플레이북·국면·MCP 게이트는 네트워크/외부 판정이라 여기서 재현하지 않고, 엔진이 + 실제 주문 직전에 다시 판정한다. 로컬 게이트(일일손실·3연패·마감·슬롯)는 `gates` 로 노출. + """ _reset_if_new_day() + _es, _st = _engine() + _st.reset_if_new_day() if not is_day_trade_entry_window(): return { "phase": "outside_window", "message": "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).", "signals": [], + "source": "engine", } portfolio = await _get_portfolio_summary() total_assets = portfolio["total_assets"] available_cash = portfolio["available_cash"] - current_day_trade_amount = sum( - p["entry_price"] * p["quantity"] for p in _positions.values() - ) + dt_amount = _engine_dt_amount() + open_slots = max(0, MAX_POSITIONS - _st.position_count()) + gates = { + "entry_cutoff_passed": not _is_new_entry_allowed(), + "daily_loss_exceeded": bool(is_daily_loss_exceeded(_st.last_total_assets, _st.daily_pnl)), + "cooldown_active": bool(_st.is_cooldown_active()), + "open_slots": open_slots, + "total_assets_unavailable": total_assets <= 0, + } - signals = await _scan_signals(total_assets, available_cash, current_day_trade_amount) + results: List[Dict[str, Any]] = [] + skipped_held = skipped_closed = 0 + if total_assets > 0: + for code, name in list(_es.get_watchlist().items()): + if code in _st.positions: + skipped_held += 1 + continue + if _st.is_closed_today(code): + skipped_closed += 1 + continue + try: + ev = await _evaluate_engine_signal_v2( + code, name, total_assets, available_cash, dt_amount + ) + except Exception as e: + logger.warning(f"[DayTrade] /signals 판정 오류 [{code}]: {e}") + continue + if ev.get("signal") is not None and int(ev.get("quantity", 0) or 0) > 0: + results.append(ev) + + results.sort(key=lambda x: x["signal"].confidence, reverse=True) + results = results[:open_slots] return { "phase": "active", + "source": "engine", + "advisory": True, + "gates": gates, + "skipped_held": skipped_held, + "skipped_closed_today": skipped_closed, "signals": [ { "stock_code": s["signal"].stock_code, @@ -862,24 +481,25 @@ async def get_current_signals(): "strategy": s["signal"].strategy, "entry_price": s["signal"].entry_price, "stop_loss": s["signal"].stop_loss, - "target_price": s["signal"].target_price, + "target_price": _finite_or_none(s["signal"].target_price), "reason": s["signal"].reason, "confidence": s["signal"].confidence, "quantity": s["quantity"], } - for s in signals + for s in results ], - "count": len(signals), + "count": len(results), } @router.post("/execute", dependencies=[Depends(require_api_key)]) async def execute_day_trade(req: ExecuteRequest): - """특정 종목 데이트레이딩 신호 스캔 후 진입. + """특정 종목 데이트레이딩 신호 확인 (dry_run 전용, 실제 엔진 상태 기준). [2026-09-23] 실매수(dry_run=False)는 비활성화. 이 경로로 진입한 포지션은 실제 엔진의 `day_trading_state`에 등록되지 않아 손절·트레일링·EOD 청산을 전혀 받지 못한다. 신호 확인용 dry_run 만 허용한다. + [2026-10-09] dry_run 판정도 레거시 빈 상태 대신 엔진 상태(`_evaluate_engine_signal_v2`). """ if not req.dry_run: raise HTTPException( @@ -890,12 +510,16 @@ async def execute_day_trade(req: ExecuteRequest): ), ) _reset_if_new_day() + _es, _st = _engine() + _st.reset_if_new_day() code = req.stock_code - name = _watchlist.get(code, code) + name = _es.get_watchlist().get(code, code) - if code in _positions: + if code in _st.positions: raise HTTPException(400, f"{code}는 이미 포지션이 있습니다.") - if len(_positions) >= MAX_POSITIONS: + if _st.is_closed_today(code): + raise HTTPException(400, f"{code}는 당일 청산(재진입 차단) 종목입니다.") + if _st.position_count() >= MAX_POSITIONS: raise HTTPException(400, f"최대 포지션 수({MAX_POSITIONS}종목)에 도달했습니다.") if not is_day_trade_entry_window(): raise HTTPException(400, "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).") @@ -910,68 +534,36 @@ async def execute_day_trade(req: ExecuteRequest): if total_assets <= 0: raise HTTPException(503, "총자산 조회에 실패했습니다. 잠시 후 다시 시도해주세요.") - if is_daily_loss_exceeded(total_assets, _daily_pnl): - raise HTTPException(400, "일일 손실 한도(-3%)에 도달하여 신규 진입이 차단됩니다.") - - candles_1m = await _fetch_candles(code) - # 분봉 데이터 없으면 신호 분석 불가 → early return - if not candles_1m: - return {"status": "no_signal", "message": "분봉 데이터를 가져올 수 없습니다."} - - candles_5m = aggregate_to_5min(candles_1m) - current_price = candles_1m[-1].close - vwap = calculate_vwap(candles_1m) - orb = _orb_cache.get(code) - - signal = None - if orb: - signal = check_orb_signal(code, name, candles_1m, orb, current_price) - if signal is None and vwap: - signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price) - if signal is None: - signal = check_rsi_bb_signal(code, name, candles_5m, current_price) - - if not signal: - return {"status": "no_signal", "message": "현재 진입 조건을 충족하는 신호가 없습니다."} - - current_day_trade_amount = sum( - p["entry_price"] * p["quantity"] for p in _positions.values() - ) - position_size = calculate_day_trade_position_size( - total_assets, available_cash, current_day_trade_amount - ) - # [항목10 2026-09-27] max(1, ...) 제거 — 예산이 0이어도 1주를 사던 문제. - quantity = int(position_size // signal.entry_price) if signal.entry_price > 0 else 0 - if quantity <= 0: - logger.info( - f"⛔ 데이트레이딩 진입 스킵 [{code}]: 예산 부족 " - f"(예산 {position_size:,.0f}원 / 단가 {signal.entry_price:,.0f}원 → 0주)" + if is_daily_loss_exceeded(total_assets, _st.daily_pnl): + raise HTTPException( + 400, + f"일일 손실 한도(-{abs(settings.DAY_TRADE_DAILY_LOSS_LIMIT) * 100:.1f}%)에 도달하여 신규 진입이 차단됩니다.", ) - return { - "status": "no_budget", - "message": ( - f"데이트레이딩 예산이 부족합니다 " - f"(가용 예산 {position_size:,.0f}원, 단가 {signal.entry_price:,.0f}원)." - ), - } - if req.dry_run: + ev = await _evaluate_engine_signal_v2(code, name, total_assets, available_cash, _engine_dt_amount()) + signal = ev.get("signal") + if signal is None: return { - "status": "dry_run", - "signal": { - "strategy": signal.strategy, - "entry_price": signal.entry_price, - "stop_loss": signal.stop_loss, - "target_price": signal.target_price, - "reason": signal.reason, - "quantity": quantity, - }, + "status": "no_signal", + "message": "현재 진입 조건을 충족하는 신호가 없습니다." + + (f" ({ev['skip']})" if ev.get("skip") else ""), } + quantity = int(ev.get("quantity", 0) or 0) + if quantity <= 0: + return {"status": "no_budget", "message": f"데이트레이딩 예산이 부족합니다 ({ev.get('skip')})."} - entry = await _execute_entry(signal, quantity) - if not entry: - raise HTTPException(500, "주문 실행에 실패했습니다.") - return {"status": "ok", "entry": entry} + return { + "status": "dry_run", + "source": "engine", + "signal": { + "strategy": signal.strategy, + "entry_price": signal.entry_price, + "stop_loss": signal.stop_loss, + "target_price": _finite_or_none(signal.target_price), + "reason": signal.reason, + "quantity": quantity, + }, + } @router.get("/positions") @@ -984,12 +576,21 @@ async def get_positions(): for code, pos in _st.positions.items(): entry_price = float(pos.get("entry_price", 0) or 0) quantity = int(pos.get("quantity", 0) or 0) - try: - current_price = await _get_current_price(code) - except Exception: - current_price = entry_price - pnl = (current_price - entry_price) * quantity - pnl_pct = ((current_price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0 + # [R7 2026-10-10] 조회 실패를 0.0 으로 받아 pnl=-진입가×수량, -100% 가 응답되던 + # 문제 수정. 실패면 엔진이 최근 관측한 현재가, 그것도 없으면 None(손익 미표시). + current_price = await _get_current_price_v2(code) + price_source = "live" + if current_price is None: + cached = _finite_or_none(pos.get("current_price")) + if cached is not None and cached > 0: + current_price, price_source = cached, "engine_cached" + else: + current_price, price_source = None, "unavailable" + if current_price is not None and entry_price > 0: + pnl = (current_price - entry_price) * quantity + pnl_pct = (current_price - entry_price) / entry_price * 100 + else: + pnl = pnl_pct = None result.append({ "stock_code": code, "stock_name": pos.get("stock_name", code), @@ -1002,8 +603,9 @@ async def get_positions(): "target_price": _finite_or_none(pos.get("target_price")), "partial_sold": bool(pos.get("partial_sold", False)), "quantity": quantity, - "pnl": _finite_or_none(round(pnl, 0)), - "pnl_pct": _finite_or_none(round(pnl_pct, 2)), + "price_source": price_source, + "pnl": _finite_or_none(round(pnl, 0)) if pnl is not None else None, + "pnl_pct": _finite_or_none(round(pnl_pct, 2)) if pnl_pct is not None else None, "entry_time": str(pos.get("entry_time", "")), }) return { @@ -1023,21 +625,126 @@ async def close_all_positions(): [2026-09-23] 이 모듈의 레거시 인메모리 상태로 청산하면 엔진 상태가 그대로 남아 30초 뒤 같은 물량을 다시 매도한다(이중 매도). 엔진 경로만 사용한다. + [R4 2026-10-09] 보유 경로도 스케줄러와 같은 `dt_cycle_lock` 안에서 force_close_all 을 + 부른다(→ `_force_close_all_locked_impl`). """ - from app.services.daytrading.exit_service import force_close_all _es, _st = _engine() _st.reset_if_new_day() if not _st.positions: - return {"status": "ok", "message": "활성 포지션 없음", "exits": [], "daily_pnl": round(_st.daily_pnl, 0)} - exits = await force_close_all(reason="수동 전량 청산 (API)") + return await _close_stale_when_empty_v2() + return await _force_close_all_locked_impl() + + +async def _force_close_all_locked_impl() -> Dict[str, Any]: + """[R4 2026-10-09] /close-all 보유 경로 — dt_cycle_lock 안에서 엔진 강제 청산. + + 예전에는 락 없이 force_close_all 을 돌아, 같은 순간 스케줄러 사이클(락 보유)의 + scan_and_enter 가 새 종목을 진입시켜 응답은 'N건 청산 완료'인데 DT 포지션이 남았다. + 락이 잡혀 있으면 /cycle·빈 메모리 경로와 같은 규약으로 기다리지 않고 409. + 청산 후에도 남은 포지션(청산 주문 진행 중·현재가/주문 실패)은 `remaining_positions` + 로 응답에 드러낸다 — '청산 완료' 문구가 잔존을 숨기지 않게. + """ + from app.services.daytrading import exit_service as _xs + from app.services.daytrading.position_manager import dt_cycle_lock + + _, _st = _engine() + if dt_cycle_lock.locked(): + raise HTTPException(409, "데이트레이딩 사이클 실행 중 — 잠시 후 재시도") + async with dt_cycle_lock: + _st.reset_if_new_day() + if not _st.positions: + # 락 확인 직후 다른 경로가 포지션을 비웠다 — 빈 메모리 경로를 락 안에서 수행. + if not is_market_open(): + return _stale_market_closed_result_v2() + stale_exits = list(await _xs.close_stale_positions() or []) + return _stale_result_v2(stale_exits) + exits = list(await _xs.force_close_all(reason="수동 전량 청산 (API)") or []) + remaining = sorted(_st.positions.keys()) + if remaining: + logger.warning( + f"[DayTrade] 수동 전량 청산 후 DT 포지션 {len(remaining)}건 잔존" + f"(청산 주문 진행 중·현재가/주문 실패 등 — 재시도 필요): {remaining}" + ) return { "status": "ok", - "message": f"데이트레이딩 포지션 {len(exits)}건 청산 완료", + "message": ( + f"데이트레이딩 포지션 {len(exits)}건 청산 완료" + + (f" — {len(remaining)}건 잔존(재시도 필요)" if remaining else "") + ), "exits": exits, + "remaining_positions": remaining, "daily_pnl": round(_st.daily_pnl, 0), } +def _stale_market_closed_result_v2() -> Dict[str, Any]: + _, _st = _engine() + return { + "status": "ok", + "message": "활성 포지션 없음 — 장 운영 시간이 아니라 전일·고아 DT 잔고 청산은 수행하지 않음", + "exits": [], "stale_exits": [], "stale_checked": False, + "daily_pnl": round(_st.daily_pnl, 0), + } + + +def _stale_result_v2(stale_exits: list) -> Dict[str, Any]: + from app.services.daytrading import exit_service as _xs + _, _st = _engine() + if stale_exits: + logger.warning(f"[DayTrade] 수동 전량 청산: 전일·고아 DT 잔고 {len(stale_exits)}건 청산") + return { + "status": "ok", + "message": ( + f"활성 포지션 없음 — 전일·고아 DT 잔고 {len(stale_exits)}건 청산" + if stale_exits else "활성 포지션 없음 — 전일·고아 DT 잔고 없음" + ), + "exits": stale_exits, + "stale_exits": stale_exits, + "stale_checked": True, + "stale_realized_pnl": round(_xs.get_stale_realized_pnl_v2(), 0), + "daily_pnl": round(_st.daily_pnl, 0), + } + + +async def _close_stale_when_empty_v2() -> Dict[str, Any]: + """[2026-10-09] /close-all — 인메모리 포지션이 없을 때 전일·고아 DT 잔고 정리. + + 재기동·자정 reset 뒤에는 인메모리가 비어도 전일 미청산·고아 DT 잔고가 KIS 에 남는다. + 예전에는 '활성 포지션 없음' 으로 즉시 반환해 수동 전량 청산이 아무것도 하지 않았다. + 스케줄러 EOD 분기(scheduler.py)와 같이 `dt_cycle_lock` 안에서 `close_stale_positions()` + 를 부른다 — 매도 수량 상한(KIS 주문가능·보유 − 타 전략 보유분, §3.5)·count_daily_pnl=False + (전일 손익 당일 미산입)·결과 불명 재주문 차단은 그 함수가 이미 보장한다. + 사이클이 락을 잡고 있으면 기다리지 않고 409(/cycle 과 같은 규약) — 그 사이클 자체가 + 전일·고아 청산을 수행한다. 장외에는 close_stale_positions 가 주문하지 않으므로 그대로 알린다. + """ + from app.services.daytrading import exit_service as _xs + from app.services.daytrading.position_manager import dt_cycle_lock + + _, _st = _engine() + if not is_market_open(): + return _stale_market_closed_result_v2() + if dt_cycle_lock.locked(): + raise HTTPException(409, "데이트레이딩 사이클 실행 중 — 잠시 후 재시도(사이클이 전일·고아 잔고도 정리함)") + async with dt_cycle_lock: + _st.reset_if_new_day() + if _st.positions: + # 락 대기 사이 다른 경로가 포지션을 복원했다 — 엔진 강제 청산으로 넘긴다. + exits = list(await _xs.force_close_all(reason="수동 전량 청산 (API)") or []) + remaining = sorted(_st.positions.keys()) + return { + "status": "ok", + "message": ( + f"데이트레이딩 포지션 {len(exits)}건 청산 완료" + + (f" — {len(remaining)}건 잔존(재시도 필요)" if remaining else "") + ), + "exits": exits, "stale_exits": [], "stale_checked": False, + "remaining_positions": remaining, + "daily_pnl": round(_st.daily_pnl, 0), + } + stale_exits = list(await _xs.close_stale_positions() or []) + return _stale_result_v2(stale_exits) + + def _finite_or_none(value): """[M2] JSON 직렬화 가능한 유한 숫자만 통과 — inf/NaN/변환 불가는 None.""" import math @@ -1063,14 +770,17 @@ async def manual_cycle(): [M5 2026-10-02] 스케줄러와 **같은 전역 락(dt_cycle_lock)** 을 잡는다. 예전에는 스케줄러 사이클과 동시에 돌아 같은 포지션을 두 번 매도하거나, 두 스캔이 슬롯·예산 검사를 동시에 통과해 한도를 넘겨 매수할 수 있었다. 락이 잡혀 있으면 기다리지 않고 - 409 를 돌려준다. 게이트: DAY_TRADE_ENABLED · 장 운영 시간 · (진입은) 진입 시간창. + 409 를 돌려준다. 게이트: 장 운영 시간 · 락 / (진입만) DAY_TRADE_ENABLED · 진입 시간창. + + [2026-10-09 R4] DAY_TRADE_ENABLED=False 여도 **청산 체크는 수행**한다. 스케줄러가 + 비활성 시 "신규 진입만 중단"하고 청산(손절·시간손절·트레일링)은 계속 돌리므로 수동 + 경로도 같은 규약을 따른다(청산은 매수보다 우선 — CLAUDE.md §8.1-11). 진입만 생략하고 + 응답 entry_skipped 에 사유를 표시한다. """ from app.services.daytrading.exit_service import check_and_exit_positions from app.services.daytrading.entry_service import scan_and_enter from app.services.daytrading.position_manager import dt_cycle_lock - if not settings.DAY_TRADE_ENABLED: - raise HTTPException(409, "DAY_TRADE_ENABLED=False — 데이트레이딩 비활성") if not is_market_open(): raise HTTPException(409, "장 운영 시간이 아님") if dt_cycle_lock.locked(): @@ -1087,7 +797,10 @@ async def manual_cycle(): exits = [] entries: list = [] entry_skipped = None - if is_day_trade_entry_window(): + # 진입 게이트는 청산 이후에, 사이클 시점의 설정값으로 판정한다(청산 판정은 막지 않음). + if not settings.DAY_TRADE_ENABLED: + entry_skipped = "DAY_TRADE_ENABLED=False" + elif is_day_trade_entry_window(): try: entries = await asyncio.wait_for(scan_and_enter(), timeout=_timeout) except asyncio.TimeoutError: @@ -1112,7 +825,11 @@ async def get_status(): "active_positions": _st.position_count(), "orb_collected": len(_st.orb_cache), "daily_pnl": round(_st.daily_pnl, 0), - "closed_today": len(_st.closed_today), + # [2026-10-08] DT 엔진 카드 '청산' — DT 포지션 전량 청산 종목 수만 센다. + # closed_today 는 재진입 차단 집합이라 스윙 손절 종목(전략 간 당일 쿨다운)도 들어있다 + # (10/08: DT 6 + 평균회귀 손절 2 = '청산 8'). 차단 집합 크기는 별도 키로 노출. + "closed_today": len(_st.dt_exited_today), + "reentry_blocked_today": len(_st.closed_today), "cooldown_active": bool(_st.is_cooldown_active()), "is_market_open": is_market_open(), "is_opening_range": is_opening_range_period(), diff --git a/backend/app/api/endpoints/manual_buy.py b/backend/app/api/endpoints/manual_buy.py index e877f55..7230e70 100644 --- a/backend/app/api/endpoints/manual_buy.py +++ b/backend/app/api/endpoints/manual_buy.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.background_tasks import spawn_tracked_v2 from app.core.config import settings from app.core.database import get_db from app.core.security import require_api_key @@ -135,11 +136,44 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession logger.info(f"🟢 수동 매수 요청: {stock_name}({stock_code}) {quantity}주 @ {request.price or '시장가'}") # ── 주문 (세마포어 + 분산락) ───────────────────────────────────────── - from app.services.trade_executor import safe_place_order, _delayed_sync_portfolio + from app.services.trade_executor import ( + safe_place_order, _delayed_sync_portfolio, _is_order_outcome_unknown_v2, + ) order_result = await safe_place_order( stock_code=stock_code, order_type="BUY", quantity=quantity, price=request.price, is_domestic=True, ) + # [R3 2026-10-09] 전송 후 결과 불명(KIS_TRANSPORT_UNKNOWN·msg_cd 없음·응답 없음)은 실제 + # 접수·체결됐을 수 있다. 단순 실패로 처리하면 쿨다운 없이 재클릭 → Portfolio 미동기 상태에서 + # 4% 누적 상한이 기존 보유분을 못 봐 이중 매수. 쿨다운을 세우고 PENDING 으로 기록한다. + if _is_order_outcome_unknown_v2(order_result): + from app.services.db_worker import enqueue_trade_history as _eth + _now = _kst_now() + _last_done[stock_code] = time.monotonic() + logger.error( + f"[MANUAL_BUY] {stock_code} 주문 결과 불명(전송 후 응답 유실) — 쿨다운 {int(_DUP_COOLDOWN_SEC)}초, " + f"PENDING 기록 후 잔고 동기화. 포트폴리오 확인 전 재시도 금지" + ) + await _eth({ + "stock_code": stock_code, + "stock_name": stock_name, + "order_type": "BUY", + "order_price": float(request.price) if request.price else None, + "order_quantity": quantity, + "executed_price": None, + "executed_quantity": 0, + "total_amount": float(order_price * quantity), + "status": "PENDING", + "order_id": None, + "message": "수동 매수 결과 불명 (접수 여부 미확인 — 포트폴리오 확인 필요)", + "strategy_type": "manual", + "created_at": _now, + }) + spawn_tracked_v2(_delayed_sync_portfolio(3)) + raise HTTPException( + status_code=504, + detail="매수 주문 결과 불명 — 접수·체결됐을 수 있습니다. 포트폴리오 확인 후 재시도하세요", + ) if not order_result or order_result.get("rt_cd") != "0": msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패" raise HTTPException(status_code=500, detail=f"매수 주문 실패: {msg}") @@ -154,23 +188,27 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession # ── DB 기록 (db_worker 큐) ──────────────────────────────────────────── from app.services.db_worker import enqueue_trade_history, enqueue_portfolio_upsert now = _kst_now() + # [R3 2026-10-09] 접수(rt_cd 0)만으로 체결을 단정하지 않는다. 지정가는 미체결일 수 있어 + # PENDING·미체결 수량 0 으로 기록하고 Portfolio 선생성을 생략한다(체결 확인/sync 담당). + is_limit = bool(request.price) await enqueue_trade_history({ "stock_code": stock_code, "stock_name": stock_name, "order_type": "BUY", "order_price": float(request.price) if request.price else None, "order_quantity": quantity, - "executed_price": float(order_price), - "executed_quantity": quantity, + # [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다 — 주문 기준가를 체결가로 쓰지 않는다. + "executed_price": None, + "executed_quantity": 0 if is_limit else quantity, "total_amount": float(order_price * quantity), - "status": "COMPLETED", + "status": "PENDING" if is_limit else "COMPLETED", "order_id": order_id, "message": "수동 매수 (추천 패널)", "strategy_type": "manual", "created_at": now, - "executed_at": now, + "executed_at": None if is_limit else now, }) - if holding is None: + if holding is None and not is_limit: await enqueue_portfolio_upsert({ "stock_code": stock_code, "stock_name": stock_name, @@ -182,8 +220,26 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession "currency_code": "KRW", "exchange_code": "KRX", }) + # [R7] 접수된 주문(ODNO)은 체결 확인 태스크로 PENDING→COMPLETED/CANCELLED 확정한다. + # 없으면 체결된 지정가 수동 매수가 영원히 PENDING·0주로 남아 통계·저널에서 빠진다. + # (미체결 잔량이 KIS 에 살아 있으면 PENDING 유지 — 취소는 하지 않는다.) + if order_id: + from app.services.trade_executor import _verify_and_sync_execution_v3 + spawn_tracked_v2(_verify_and_sync_execution_v3( + order_id=order_id, + stock_code=stock_code, + stock_name=stock_name, + ordered_quantity=quantity, + order_price=float(order_price), + order_type="BUY", + is_domestic=True, + exchange_code="KRX", + db_trade_id=0, + delay=3, + sell_pnl_basis={"strategy_type": "manual"}, + )) # 체결 후 KIS 잔고 기준 재동기화 (평균단가·수량 정확화) - asyncio.create_task(_delayed_sync_portfolio(3)) + spawn_tracked_v2(_delayed_sync_portfolio(3)) _last_done[stock_code] = time.monotonic() logger.info(f"✅ 수동 매수 주문 완료: {stock_name} {quantity}주 @ {order_price:,.0f}원 (주문번호 {order_id})") diff --git a/backend/app/api/endpoints/market_data.py b/backend/app/api/endpoints/market_data.py index 5c76773..4400a7b 100644 --- a/backend/app/api/endpoints/market_data.py +++ b/backend/app/api/endpoints/market_data.py @@ -9,7 +9,9 @@ # 필요한 라이브러리 및 모듈 임포트 from datetime import datetime, timedelta import asyncio +import time import random +import re import logging import pytz @@ -260,16 +262,16 @@ async def _fetch_chart_from_db(stock_code: str, limit: int) -> list: DB에 데이터가 없으면 빈 리스트 반환. [READ-ONLY 함수 — DB 쓰기 없음] - SELECT만 수행하므로 CLAUDE.md 3.1 "실시간 계층 DB I/O 금지" 규칙의 예외 대상. - 단, WebSocket 경로에서 이 함수를 직접 호출하면 안 됩니다. - WebSocket 핸들러는 get_chart_data_cached()를 통해 인메모리 캐시(1차)를 우선 사용하고, - 캐시 미스 시에도 이 함수를 호출하지 말고 캐시된 값(빈 리스트 포함)을 반환해야 합니다. - REST 엔드포인트에서만 DB 폴백이 허용됩니다. + REST /chart 와 WebSocket get_chart(백그라운드 태스크, 인메모리 캐시 미스 시) 양쪽에서 호출된다. + SELECT 1회만 수행하고 add/commit 이 없다. KIS 수신 루프·주문 경로에서는 호출되지 않는다. + [M-5] OHLC 중 0/결측이 있는 행(거래정지·수집 결함)은 제외 — 네이버 경로와 동일 기준. """ try: from app.core.database import AsyncSessionLocal from app.models.models import StockDailyPrice + # DB-READ-EXCEPTION: 차트 일봉 SELECT 전용(commit·add 없음). WS get_chart 는 메시지 루프가 아닌 + # 백그라운드 태스크에서 캐시 미스일 때만 도달한다. 쓰기는 이 경로에 추가하지 말 것. async with AsyncSessionLocal() as db: result = await db.execute( select(StockDailyPrice) @@ -284,51 +286,348 @@ async def _fetch_chart_from_db(stock_code: str, limit: int) -> list: # DB 결과는 newest first → oldest first로 역순 변환 chart_data = [] + skipped = 0 for row in reversed(rows): + o = float(row.open_price or 0) + h = float(row.high_price or 0) + l = float(row.low_price or 0) + c = float(row.close_price or 0) + if o <= 0 or h <= 0 or l <= 0 or c <= 0: + skipped += 1 + continue chart_data.append({ "date": row.trade_date.strftime("%Y%m%d"), - "open": float(row.open_price or 0), - "high": float(row.high_price or 0), - "low": float(row.low_price or 0), - "close": float(row.close_price or 0), + "open": o, + "high": h, + "low": l, + "close": c, "volume": int(row.volume or 0), }) + if skipped: + logger.info(f"차트 DB 0/결측 OHLC 행 제외 ({stock_code}): {skipped}행") return chart_data except Exception as e: logger.warning(f"DB 차트 조회 실패 ({stock_code}): {e}") return [] -async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> list: - """캐시된 차트 데이터 반환 — REST endpoint와 WebSocket 핸들러가 공유. +_CHART_LIVE_TTL = 30 # 당일 캔들을 합성/병합한 결과의 캐시 TTL(초) - 조회 우선순위: - 1. 인메모리 캐시 (10분 TTL) - 2. DB (stock_daily_prices) — 일봉 전용, 배치가 사전 수집한 데이터 - 3. KIS API 폴백 (DB에 데이터 부족 시) - realtime 보정 없음 (현재가는 WebSocket tick이 처리). +def _chart_session_state(now_kst: datetime) -> tuple: + """(expected_last_date, in_session) — 일봉에 있어야 할 가장 최근 거래일과 장중 여부. + + expected_last_date: 오늘이 거래일이고 09:00 이후면 오늘, 아니면 직전 거래일(YYYYMMDD). + """ + from app.core.market_schedule import is_krx_holiday + d = now_kst.date() + is_trading_day = d.weekday() < 5 and not is_krx_holiday(d) + started = now_kst.time() >= datetime.strptime("09:00", "%H:%M").time() + in_session = is_trading_day and started and now_kst.time() < datetime.strptime("15:30", "%H:%M").time() + if is_trading_day and started: + return d.strftime("%Y%m%d"), in_session + d -= timedelta(days=1) + while d.weekday() >= 5 or is_krx_holiday(d): + d -= timedelta(days=1) + return d.strftime("%Y%m%d"), False + + +def _naver_rows_to_bars(naver_data: list) -> list: + rows = [] + for item in naver_data or []: + o = float(item.get("stck_oprc") or 0) + h = float(item.get("stck_hgpr") or 0) + l = float(item.get("stck_lwpr") or 0) + c = float(item.get("stck_clpr") or 0) + d = item.get("stck_bsop_date", "") + if not d or c <= 0 or o <= 0 or h <= 0 or l <= 0: + continue + rows.append({"date": d, "open": o, "high": h, "low": l, "close": c, + "volume": int(item.get("acml_vol") or 0)}) + return rows + + +_DAYBAR_KEY_PREFIX = "daybar:" +_DAYBAR_MIN_TTL = 60 # 저장 TTL 하한(초) +_CHART_POST_CLOSE_END = "15:40" # 장 마감 직후(단일가 체결 전 값 가능) 짧은 캐시 구간 끝 + + +def _daybar_ttl_seconds(now_kst: datetime) -> int: + """daybar 키 TTL — 당일 15:40(KST)까지 남은 초(하한 _DAYBAR_MIN_TTL).""" + end = datetime.strptime(_CHART_POST_CLOSE_END, "%H:%M").time() + end_dt = now_kst.replace(hour=end.hour, minute=end.minute, second=0, microsecond=0) + return max(_DAYBAR_MIN_TTL, int((end_dt - now_kst).total_seconds())) + + +def _is_post_close_window(now_kst: datetime) -> bool: + """거래일 15:30~15:40 — 네이버 당일봉이 단일가 체결 전 값일 수 있어 길게 캐시하지 않는다.""" + from app.core.market_schedule import is_krx_holiday + d = now_kst.date() + if d.weekday() >= 5 or is_krx_holiday(d): + return False + t = now_kst.time() + return (datetime.strptime("15:30", "%H:%M").time() <= t + < datetime.strptime(_CHART_POST_CLOSE_END, "%H:%M").time()) + + +def _parse_bar(raw, expected: str) -> dict | None: + """OHLC 가 모두 양수인 당일 봉 dict 만 통과(날짜는 expected 와 같아야 함).""" + if not isinstance(raw, dict): + return None + try: + price = float(raw.get("price", raw.get("close")) or 0) + op = float(raw.get("open") or 0) + hi = float(raw.get("high") or 0) + lo = float(raw.get("low") or 0) + vol = int(raw.get("volume") or 0) + except (TypeError, ValueError): + return None + if price <= 0 or op <= 0 or hi <= 0 or lo <= 0: + return None + return {"date": expected, "open": op, "high": max(hi, price, op), + "low": min(lo, price, op), "close": price, "volume": vol} + + +async def _today_bar_from_tick(stock_code: str, expected: str) -> dict | None: + """Redis tick:{code}(KIS 실시간 체결, 시가/고가/저가 포함)으로 당일 봉 합성. + + tick 은 TTL 10초라 저유동 종목은 자주 비므로, 합성한 당일봉을 daybar:{code}(TTL 15:40 까지)에 + 누적(고가=max, 저가=min)해 두고 tick 이 없을 때 그 값을 쓴다. 둘 다 없거나 불완전하면 None. + """ + from app.core.redis import get_json, setex_json + tick = None + try: + tick = await get_json(f"tick:{stock_code}") + except Exception as e: + warn_throttled(logger, "market_data:tick_bar", "tick 캐시 조회 실패 (%s): %s", stock_code, e) + saved = None + try: + raw_saved = await get_json(f"{_DAYBAR_KEY_PREFIX}{stock_code}") + if isinstance(raw_saved, dict) and str(raw_saved.get("date")) == expected: # 전일 잔존 값 무시 + saved = _parse_bar(raw_saved, expected) + except Exception as e: + warn_throttled(logger, "market_data:daybar_get", "daybar 조회 실패 (%s): %s", stock_code, e) + bar = _parse_bar(tick, expected) if isinstance(tick, dict) else None + if bar is None: + return saved + if saved is not None: + # 시가는 tick(KIS 당일 시가) 우선, 고저는 누적 극값 유지 + bar["high"] = max(bar["high"], saved["high"]) + bar["low"] = min(bar["low"], saved["low"]) + try: + await setex_json(f"{_DAYBAR_KEY_PREFIX}{stock_code}", + _daybar_ttl_seconds(datetime.now(_KST)), bar) + except Exception as e: + warn_throttled(logger, "market_data:daybar_set", "daybar 저장 실패 (%s): %s", stock_code, e) + return bar + + +def _missing_trading_days_v2(bars: list, now_kst: datetime) -> list: + """bars 첫 날짜~마지막 날짜 사이, 최근 10 캘린더일 창 안에서 빠진 거래일(YYYYMMDD).""" + from app.core.market_schedule import is_krx_holiday + try: + have = {str(b.get("date"))[:8] for b in bars} + first = datetime.strptime(min(have), "%Y%m%d").date() + last = datetime.strptime(max(have), "%Y%m%d").date() + except (ValueError, TypeError): + return [] + d = max(first, now_kst.date() - timedelta(days=10)) + missing = [] + while d < last: + ds = d.strftime("%Y%m%d") + if d.weekday() < 5 and not is_krx_holiday(d) and ds not in have: + missing.append(ds) + d += timedelta(days=1) + return missing + + +_CHART_SPLIT_MISMATCH_RATIO = 0.20 # 분할·수정주가 판정 임계(종가 비율 편차) + + +async def _merge_recent_daily_bars(stock_code: str, bars: list, now_kst: datetime | None = None) -> tuple: + """DB 일봉이 최신 거래일보다 뒤처졌을 때 누락 봉을 채운다. (bars, live_merged) 반환. + + 원인(2026-10-08): DB(stock_daily_prices) 갱신이 하루 이상 늦어도 _CHART_STALE_DAYS(7일) + 미만이면 폴백이 없고, 장중 당일 봉은 어디서도 합성되지 않아 차트가 멈춰 보였다. + 1) 누락 거래일: 네이버 최근 일봉으로 보충 (읽기 전용 HTTP, DB 쓰기 없음) + 2) 장중: 당일 봉을 Redis tick(시가/고가/저가/현재가/누적거래량)으로만 합성 (KIS REST 호출 없음) + 예외는 경고만 남기고 원본 bars 를 반환한다(차트는 계속 나와야 한다). + """ + now_kst = now_kst or datetime.now(_KST) + expected, in_session = _chart_session_state(now_kst) + out = list(bars) + last = str(out[-1].get("date", ""))[:8] if out else "" + live = False + + # 최근 10일 창 안의 중간 결손 거래일(예: DB 일괄 수집 누락) 탐지 — last 이전 결손도 백필 대상 + gaps = _missing_trading_days_v2(out, now_kst) if out else [] + if not last or last < expected or gaps: + try: + from app.services.naver_finance import get_naver_daily_chart + naver_rows = _naver_rows_to_bars(await get_naver_daily_chart(stock_code, days=10)) + have = {str(b.get("date"))[:8]: b for b in out} + # 장중 네이버의 당일 진행 봉은 부분봉이다 → 버린다(당일봉은 tick 합성만 인정) + if in_session: + naver_rows = [r for r in naver_rows if r["date"] != expected] + # 원주가/수정주가 혼재 방지: 겹치는 날짜 종가가 분할 수준(>20%)으로 어긋날 때만 병합 안 함. + # 네이버-KRX 일상적 차이(최대 ~1.3%)는 DB 봉을 유지하고 누락 날짜만 보충한다. + mismatch = any( + float(have[r["date"]].get("close") or 0) > 0 + and abs(r["close"] / float(have[r["date"]]["close"]) - 1) > _CHART_SPLIT_MISMATCH_RATIO + for r in naver_rows if r["date"] in have + ) + if mismatch: + logger.warning(f"차트 보충 생략({stock_code}): DB/네이버 종가 불일치(분할·수정주가 추정)") + else: + first = str(out[0].get("date"))[:8] if out else "" + added = [r for r in naver_rows if r["date"] not in have and r["date"] > first] + if added: + out.extend(added) + out.sort(key=lambda b: b["date"]) + last = out[-1]["date"] + logger.info(f"차트 누락 봉 보충({stock_code}): {[a['date'] for a in added]}") + except Exception as e: + logger.warning(f"차트 누락 봉 보충 실패 ({stock_code}): {e}") + + if in_session: + # [M-7] 실시간 체결 캐시(Redis tick:{code}, TTL 10초)로 합성 + tick_bar = await _today_bar_from_tick(stock_code, expected) + if tick_bar is not None: + if out and str(out[-1].get("date"))[:8] == expected: + out[-1] = tick_bar + else: + out.append(tick_bar) + return out, True + # [R1 하드닝] 차트 경로는 KIS REST 를 호출하지 않는다(주문·청산과 전역 쓰로틀 공유 방지). + # tick 이 없으면(WS 등록 한도 밖 종목 등) 당일봉 없이 DB/네이버 봉만 반환한다. + return out, live + + +_CHART_MAX_LIMIT = 240 +# 네이버 폴백 조회 기간(캘린더일) — 주/월봉은 집계 전 일봉이 충분해야 한다(L-4). +# 캘린더 ×7 / ×31 ≈ 거래일 ×5 / ×22. +_NAVER_DAYS_PER_BAR = {"D": 2, "W": 7, "M": 31} +_NAVER_MAX_DAYS = 7500 + +_OVERSEAS_TICKER_RE = re.compile(r"[A-Za-z][A-Za-z.\-]{0,9}") + + +def _is_overseas_ticker(stock_code: str) -> bool: + """미국 티커 여부. 국내 단축코드(6자리, 숫자 포함 — 예: 005930, 0004V0)는 False.""" + c = (stock_code or "").strip() + return bool(c) and not any(ch.isdigit() for ch in c) and bool(_OVERSEAS_TICKER_RE.fullmatch(c)) + + +def parse_chart_limit_v2(raw, default: int = 200) -> int: + """[L-3] 클라이언트가 보낸 limit 을 안전하게 정수화(1~240). 변환 불가면 default.""" + try: + value = int(raw) + except (TypeError, ValueError): + return default + return max(1, min(value, _CHART_MAX_LIMIT)) + + +def is_chart_cache_fresh_v2(stock_code: str, period: str = "D", limit: int = 200) -> bool: + """[M-7] 차트 캐시 엔트리가 자신의 ttl(장중 합성 30초 / 그 외 10분) 안에 있는지.""" + cached = chart_cache.get(f"{stock_code}__{period}__{min(limit, _CHART_MAX_LIMIT)}") + if not cached: + return False + age = (datetime.now() - cached["timestamp"]).total_seconds() + return age < cached.get("ttl", _CHART_CACHE_TTL) + + +def _chart_cache_put(cache_key: str, data: list, ttl: int, current_time: datetime) -> None: + # 캐시 크기 제한: 초과 시 만료 항목 → 가장 오래된 항목 순으로 제거 + if len(chart_cache) >= _CHART_CACHE_MAX_SIZE: + expired_keys = [ + k for k, v in chart_cache.items() + if (current_time - v["timestamp"]).total_seconds() >= v.get("ttl", _CHART_CACHE_TTL) + ] + for k in expired_keys: + del chart_cache[k] + while len(chart_cache) >= _CHART_CACHE_MAX_SIZE: + oldest_key = min(chart_cache, key=lambda k: chart_cache[k]["timestamp"]) + del chart_cache[oldest_key] + chart_cache[cache_key] = {"timestamp": current_time, "data": data, "ttl": ttl} + + +def _overseas_rows_to_bars(result) -> list: + """KIS 해외 일봉(HHDFS76240000) output2 → 차트 봉 (oldest first). 0/결측 OHLC 제외.""" + items = result.get("output2", []) if isinstance(result, dict) else (result or []) + bars = [] + for item in items or []: + try: + o = float(item.get("open") or 0) + h = float(item.get("high") or 0) + l = float(item.get("low") or 0) + c = float(item.get("clos") or 0) + d = str(item.get("xymd") or "") + vol = int(float(item.get("tvol") or 0)) + except (TypeError, ValueError, AttributeError): + continue + if len(d) != 8 or o <= 0 or h <= 0 or l <= 0 or c <= 0: + continue + bars.append({"date": d, "open": o, "high": h, "low": l, "close": c, "volume": vol}) + bars.sort(key=lambda b: b["date"]) + return bars + + +async def _get_overseas_chart_impl(stock_code: str, period: str, limit: int) -> dict: + """[H-6] 미국 티커 차트 — 기존 해외 차트 경로(KIS HHDFS76240000, Redis 5분 캐시) 사용.""" + cache_key = f"{stock_code}__{period}__{limit}" + current_time = datetime.now() + try: + from app.services.exchange_detector import detect_exchange + result = await korea_investment_api.get_overseas_daily_chart( + stock_code, detect_exchange(stock_code), period + ) + bars = _overseas_rows_to_bars(result)[-limit:] + except Exception as e: + warn_throttled(logger, "market_data:overseas_chart", + "해외 차트 조회 실패 (%s): %s", stock_code, e) + bars = [] + if not bars: + return {"data": [], "is_sample": False, "reason": "overseas_chart_unavailable", "source": None} + _chart_cache_put(cache_key, bars, _CHART_CACHE_TTL, current_time) + return {"data": list(bars), "is_sample": False, "reason": None, "source": "overseas"} + + +async def get_chart_data_cached_v2(stock_code: str, period: str, limit: int) -> dict: + """차트 데이터 + 메타 반환 — REST /chart 와 WebSocket get_chart 가 공유. + + 반환: {"data": [...], "is_sample": False, "reason": None|"no_data"|"overseas_chart_unavailable", + "source": "cache"|"db"|"naver"|"overseas"|None} + + 조회 우선순위 (국내): + 1. 인메모리 캐시 (엔트리별 ttl — 장중 합성 30초 / 그 외 10분) + 2. DB (stock_daily_prices) — 0/결측 OHLC 제외 + 3. 네이버 일봉 폴백 (DB 없음/오래됨) + + 누락 거래일 보충·장중 당일봉 합성(_merge_recent_daily_bars) + + [H-6] 데이터가 없으면 난수 샘플을 만들지 않고 빈 배열 + reason 을 돌려준다. + (과거 _generate_sample_chart 난수 차트가 실데이터처럼 표시됐다.) """ if period not in ("D", "W", "M"): period = "D" - limit = min(limit, 240) + limit = max(1, min(int(limit), _CHART_MAX_LIMIT)) cache_key = f"{stock_code}__{period}__{limit}" current_time = datetime.now() - # 1차: 인메모리 캐시 (TTL 만료 항목 삭제 + 크기 제한) + # 1차: 인메모리 캐시 (TTL 만료 항목 삭제) cached = chart_cache.get(cache_key) if cached: - if (current_time - cached["timestamp"]).total_seconds() < _CHART_CACHE_TTL: - return list(cached["data"]) - else: - # 만료 항목 즉시 삭제 - del chart_cache[cache_key] + if (current_time - cached["timestamp"]).total_seconds() < cached.get("ttl", _CHART_CACHE_TTL): + return {"data": list(cached["data"]), "is_sample": False, "reason": None, "source": "cache"} + del chart_cache[cache_key] - # 2차: DB 조회 (KIS API 사용 안 함 — 배치가 수집한 일봉 데이터만 사용) - # 주/월봉은 일봉 데이터에서 집계 + if _is_overseas_ticker(stock_code): + return await _get_overseas_chart_impl(stock_code, period, limit) + + # 2차: DB 조회 — 주/월봉은 일봉 데이터에서 집계 db_limit = limit if period == "D" else limit * 7 if period == "W" else limit * 31 db_data = await _fetch_chart_from_db(stock_code, min(db_limit, 1200)) + source = "db" # DB 데이터가 없거나 오래됐으면(마지막 봉이 _CHART_STALE_DAYS일 이상 전) 네이버 차트 폴백 # — KRX 일괄 수집(DATAGOKR)이 멈춘 종목은 DB가 수개월 전에서 끊겨 차트가 현재가와 어긋난다. @@ -344,56 +643,61 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis logger.info(f"차트 DB 데이터 오래됨 ({stock_code}, 마지막 {db_data[-1].get('date')}) → 네이버 폴백") try: from app.services.naver_finance import get_naver_daily_chart - naver_data = await get_naver_daily_chart(stock_code, days=limit * 2) - if naver_data: - naver_rows = [] - for item in naver_data: - o = float(item.get("stck_oprc") or 0) - h = float(item.get("stck_hgpr") or 0) - l = float(item.get("stck_lwpr") or 0) - c = float(item.get("stck_clpr") or 0) - d = item.get("stck_bsop_date", "") - if not d or c <= 0 or o <= 0 or h <= 0 or l <= 0: - continue - naver_rows.append({ - "date": d, - "open": o, - "high": h, - "low": l, - "close": c, - "volume": int(item.get("acml_vol") or 0), - }) - if naver_rows: - db_data = naver_rows[-limit:] + naver_days = min(limit * _NAVER_DAYS_PER_BAR[period], _NAVER_MAX_DAYS) + naver_rows = _naver_rows_to_bars(await get_naver_daily_chart(stock_code, days=naver_days)) + if naver_rows: + # 일봉은 limit 개, 주/월봉은 집계 전이므로 전부 유지(집계 후 limit 으로 자른다) + db_data = naver_rows[-limit:] if period == "D" else naver_rows + source = "naver" logger.info(f"차트 네이버 폴백: {stock_code} ({len(db_data)}행)") except Exception as e: logger.warning(f"네이버 차트 폴백 실패 ({stock_code}): {e}") if not db_data: - return _generate_sample_chart(stock_code, period, limit).get("data", []) + return {"data": [], "is_sample": False, "reason": "no_data", "source": None} - # 주/월봉 집계 - if period in ("W", "M"): - db_data = _aggregate_weekly_monthly(db_data, period) - db_data = db_data[-limit:] # 최신 N개만 + # 최신 거래일 봉 보충 + 장중 당일 봉 합성 (일봉 원본에 먼저 적용 → 주/월봉에도 반영) + db_data, _live = await _merge_recent_daily_bars(stock_code, db_data) + if period == "D": + db_data = db_data[-limit:] + else: + db_data = _aggregate_weekly_monthly(db_data, period)[-limit:] - # 캐시 크기 제한: 초과 시 가장 오래된 항목 제거 - if len(chart_cache) >= _CHART_CACHE_MAX_SIZE: - # 만료 항목 우선 정리 - expired_keys = [ - k for k, v in chart_cache.items() - if (current_time - v["timestamp"]).total_seconds() >= _CHART_CACHE_TTL - ] - for k in expired_keys: - del chart_cache[k] - # 그래도 초과면 가장 오래된 항목 제거 - while len(chart_cache) >= _CHART_CACHE_MAX_SIZE: - oldest_key = min(chart_cache, key=lambda k: chart_cache[k]["timestamp"]) - del chart_cache[oldest_key] + # 장중이면 tick 유무와 무관하게 짧은 TTL — 구독 워밍업이 첫 tick 보다 먼저 끝나 + # 당일봉 없는 결과가 10분 캐시되는 것을 막는다. + _now_kst = datetime.now(_KST) + _, _in_session = _chart_session_state(_now_kst) + _short = _live or _in_session or _is_post_close_window(_now_kst) + _chart_cache_put(cache_key, db_data, + _CHART_LIVE_TTL if _short else _CHART_CACHE_TTL, current_time) + return {"data": list(db_data), "is_sample": False, "reason": None, "source": source} - chart_cache[cache_key] = {"timestamp": current_time, "data": db_data} - logger.debug(f"차트 DB 히트: {stock_code} period={period} ({len(db_data)}행)") - return db_data + +async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> list: + """캐시된 차트 데이터(list) 반환 — 호환 래퍼. 메타가 필요하면 get_chart_data_cached_v2. + + 데이터가 없으면 빈 리스트(샘플 난수 차트를 반환하지 않는다 — H-6). + """ + return (await get_chart_data_cached_v2(stock_code, period, limit))["data"] + + +async def build_ws_chart_response_v2(stock_code: str, period, limit_raw) -> dict: + """WebSocket `chart_data` 응답 생성. 예외는 빈 배열 + reason/error 로 변환한다. + + 필드: type, stock_code, period(요청값 그대로 — 프론트가 이것으로 응답을 매칭), data, + is_sample(항상 False), reason(None|no_data|overseas_chart_unavailable|chart_fetch_failed), + error(None|예외 종류명) + """ + limit = parse_chart_limit_v2(limit_raw) + try: + res = await get_chart_data_cached_v2(stock_code, period, limit) + return {"type": "chart_data", "stock_code": stock_code, "period": period, + "data": res["data"], "is_sample": False, "reason": res["reason"], "error": None} + except Exception as e: + logger.error(f"get_chart 오류 ({stock_code}): {type(e).__name__}: {e}") + return {"type": "chart_data", "stock_code": stock_code, "period": period, + "data": [], "is_sample": False, "reason": "chart_fetch_failed", + "error": type(e).__name__} @router.get("/chart/{stock_code}") @@ -403,32 +707,38 @@ async def get_stock_chart( realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환"), limit: int = Query(100, description="조회할 데이터 개수 (기본: 100, 최대: 240)") ): - """주식 차트 데이터 조회 엔드포인트 (DB 전용 — KIS API 미사용) + """주식 차트 데이터 조회 엔드포인트 Args: - stock_code: 종목 코드 + stock_code: 종목 코드 (국내 6자리 또는 미국 티커) period: D(일봉), W(주봉), M(월봉) limit: 조회할 데이터 개수 (일봉 100개는 약 3개월치 데이터) + + 데이터가 없으면 data=[] + reason. is_sample 은 항상 False(샘플 난수 차트를 만들지 않는다). """ try: - chart_data = await get_chart_data_cached(stock_code, period, limit) - + res = await get_chart_data_cached_v2(stock_code, period, parse_chart_limit_v2(limit, 100)) return { "stock_code": stock_code, "period": period, - "data": chart_data, - "is_sample": not chart_data, + "data": res["data"], + "is_sample": False, + "reason": res["reason"], "realtime": False, "latest": None, } - except Exception as e: - sample = _generate_sample_chart(stock_code, period) - sample["error"] = str(e) - sample["is_sample"] = True - sample["realtime"] = False - sample["latest"] = None - return sample + logger.error(f"차트 조회 오류 ({stock_code}): {type(e).__name__}: {e}") + return { + "stock_code": stock_code, + "period": period, + "data": [], + "is_sample": False, + "reason": "chart_fetch_failed", + "error": type(e).__name__, + "realtime": False, + "latest": None, + } @router.get("/news") @@ -488,13 +798,134 @@ async def get_market_news(limit: int = 10): ][:limit] +# ── 종목명 해석 ───────────────────────────────────────────────────────────── +# KIS inquire-price(FHKST01010100)/호가 응답에는 종목명(hts_kor_isnm)이 없거나 비어 있어 +# 화면에 "009420 009420" 으로 보였다(2026-10-08). 폴백 순서: +# KIS 응답 필드 → 프로세스 캐시 → DB(StockAnalysis → Portfolio → StockDailyPrice) → 네이버 basic API +_STOCK_NAME_MEM: Dict[str, str] = {} +_STOCK_NAME_MEM_MAX = 2000 +_STOCK_NAME_MISS: Dict[str, float] = {} # code -> monotonic ts (이름 못 찾음 네거티브 캐시) +_STOCK_NAME_MISS_TTL = 600.0 # 초 + + +def _name_from_kis_output(*outputs) -> str: + for o in outputs: + if not isinstance(o, dict): + continue + for k in ("hts_kor_isnm", "prdt_abrv_name", "stck_prdt_name", "prdt_name"): + v = str(o.get(k) or "").strip() + if v: + return v + return "" + + +async def _resolve_stock_name(stock_code: str, kis_name: str = "") -> str: + """종목명 해석. 못 찾으면 "" (코드로 대체하지 않는다 — 표시는 프론트 몫).""" + name = (kis_name or "").strip() + if name: + _STOCK_NAME_MEM[stock_code] = name + return name + cached = _STOCK_NAME_MEM.get(stock_code) + if cached: + return cached + _miss_ts = _STOCK_NAME_MISS.get(stock_code) + if _miss_ts is not None: + if (time.monotonic() - _miss_ts) < _STOCK_NAME_MISS_TTL: + return "" + _STOCK_NAME_MISS.pop(stock_code, None) + try: + from app.core.database import AsyncSessionLocal + from app.models.models import StockAnalysis, Portfolio, StockDailyPrice + # DB-READ-EXCEPTION: READ-ONLY SELECT (REST 엔드포인트, commit 없음) + async with AsyncSessionLocal() as _db: + for model, order in ( + (StockAnalysis, StockAnalysis.analysis_date.desc()), + (Portfolio, None), + (StockDailyPrice, StockDailyPrice.trade_date.desc()), + ): + q = select(model.stock_name).where( + model.stock_code == stock_code, + model.stock_name.isnot(None), + model.stock_name != "", + ) + if order is not None: + q = q.order_by(order) + row = (await _db.execute(q.limit(1))).scalar_one_or_none() + if row and str(row).strip() and str(row).strip() != stock_code: + name = str(row).strip() + break + except Exception as e: + warn_throttled(logger, "market_data:name_db", "종목명 DB 조회 실패 (%s): %s", stock_code, e) + if not name: + try: + from app.services.naver_finance import get_naver_stock_name + name = await get_naver_stock_name(stock_code) + except Exception as e: + warn_throttled(logger, "market_data:name_naver", "종목명 네이버 조회 실패 (%s): %s", stock_code, e) + if name: + if len(_STOCK_NAME_MEM) >= _STOCK_NAME_MEM_MAX: + _STOCK_NAME_MEM.pop(next(iter(_STOCK_NAME_MEM)), None) + _STOCK_NAME_MEM[stock_code] = name + _STOCK_NAME_MISS.pop(stock_code, None) + else: + # 이름 없음도 짧게 캐시 — 호가 폴링마다 DB 3회 + 네이버 1회 반복 방지 + if len(_STOCK_NAME_MISS) >= _STOCK_NAME_MEM_MAX: + _STOCK_NAME_MISS.pop(next(iter(_STOCK_NAME_MISS)), None) + _STOCK_NAME_MISS[stock_code] = time.monotonic() + return name + + +# 호가 응답이 4.5~13초였던 원인(2026-10-08): 호가 1건이 KIS 전역 쓰로틀 락·세마포어에서 +# 현재가/잔고/스케줄러 호출 뒤에 줄을 서고, 화면 폴링마다 같은 종목을 중복 조회했다. +# → 종목별 짧은 TTL 캐시 + 동시 요청 합치기(in-flight). 실패 응답은 캐시하지 않는다. +_ORDERBOOK_TTL = 1.5 # 초 +_ORDERBOOK_CACHE_MAX = 200 +_orderbook_cache: Dict[str, tuple] = {} # code -> (monotonic ts, payload) +_orderbook_inflight: Dict[str, "asyncio.Future"] = {} + + @router.get("/orderbook/{stock_code}") async def get_orderbook(stock_code: str): - """호가 정보 조회 엔드포인트 (매수/매도 10단계 호가)""" + """호가 정보 조회 엔드포인트 (매수/매도 10단계 호가) — 1.5초 TTL 캐시 + 동시요청 합치기.""" + import time as _time + now = _time.monotonic() + hit = _orderbook_cache.get(stock_code) + if hit and now - hit[0] < _ORDERBOOK_TTL: + return hit[1] + fut = _orderbook_inflight.get(stock_code) + if fut is not None: + return await asyncio.shield(fut) + + fut = asyncio.get_running_loop().create_future() + _orderbook_inflight[stock_code] = fut + try: + result = await _get_orderbook_impl(stock_code) + if isinstance(result, dict) and result.get("asks") and not result.get("error"): + if len(_orderbook_cache) >= _ORDERBOOK_CACHE_MAX: + _orderbook_cache.pop(min(_orderbook_cache, key=lambda k: _orderbook_cache[k][0]), None) + _orderbook_cache[stock_code] = (_time.monotonic(), result) + fut.set_result(result) + return result + except BaseException as e: + if not fut.done(): + fut.set_exception(e) + fut.exception() # 대기자가 없을 때 'never retrieved' 경고 방지 + raise + finally: + _orderbook_inflight.pop(stock_code, None) + + +async def _get_orderbook_impl(stock_code: str): try: logger.debug(f"Fetching orderbook for stock: {stock_code}") - # 한국투자증권 API를 통해 호가 데이터 조회 - data = await korea_investment_api.get_orderbook(stock_code) + # 종목명 해석은 KIS 호출과 병렬로 (추가 지연 방지) + _name_task = asyncio.create_task(_resolve_stock_name(stock_code)) + try: + # 한국투자증권 API를 통해 호가 데이터 조회 + data = await korea_investment_api.get_orderbook(stock_code) + except BaseException: + _name_task.cancel() + raise # API 응답 코드가 '0'(성공)이 아닌 경우 에러 처리 if data.get("rt_cd") != "0": @@ -518,7 +949,9 @@ async def get_orderbook(stock_code: str): # 호가 데이터를 담을 딕셔너리 초기화 orderbook = { "stock_code": stock_code, - "stock_name": output1.get("hts_kor_isnm", ""), + "stock_name": await _resolve_stock_name( + stock_code, _name_from_kis_output(output1, output2) or (await _name_task) + ), "current_price": current_price, "current_volume": current_volume, "asks": [], # 매도 호가 (낮은 가격 -> 높은 가격 순으로 임시 저장 후 reverse) @@ -847,10 +1280,10 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)): signals.append(f"RSI 과매수({rsi:.1f})") elif rsi < 40: # 매수 고려 buy_signals += 0.5 - signals.append(f"RSI 매수권장({rsi:.1f})") + signals.append(f"RSI 저점권({rsi:.1f}, 약한 매수 우위)") elif rsi > 60: # 매도 고려 sell_signals += 0.5 - signals.append(f"RSI 매도권장({rsi:.1f})") + signals.append(f"RSI 고점권({rsi:.1f}, 약한 매도 우위)") # 이동평균선(MA) 분석 ma5 = indicators.get("ma5") @@ -961,9 +1394,26 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)): trend = "DOWN" # 4. 종합 결과 반환 + # 제목/추천/목표가는 최종 결론(recommendation)과 반드시 일치해야 한다. + # (2026-10-08: HOLD 13% 인데 '매수 추천'·목표가·손절가가 함께 표시된 모순) + _targets = _suggest_targets(current_price, _atr_for_targets) + if recommendation != "BUY": + # 목표가·손절가는 '신규 매수 진입' 기준 값이므로 BUY 결론에서만 의미가 있다. + _targets = { + "target_price": None, + "stop_loss_price": None, + "target_pct": None, + "stop_loss_pct": None, + "risk_reward": None, + "target_note": f"{recommendation} 결론 — 신규 진입 목표가·손절가 제시 안 함", + } + _headline = {"BUY": "매수 추천", "SELL": "매도 추천", "HOLD": "관망"}.get(recommendation, "관망") + _stock_name = await _resolve_stock_name(stock_code, _name_from_kis_output(output)) return { "stock_code": stock_code, - "stock_name": output.get("hts_kor_isnm", "") or output.get("stck_prdt_name", ""), + "stock_name": _stock_name, + "action": recommendation, + "headline": _headline, "current_price": current_price, "high_price": high_price, "low_price": low_price, @@ -976,7 +1426,7 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)): # 매수 추천 금액 (실제 자동매매 사이징과 동일 기준) **_suggest_buy_amount(current_price, recommendation, confidence, stock_code), # 목표가·손절가 (CLAUDE.md §5.2) - **_suggest_targets(current_price, _atr_for_targets), + **_targets, "analyzed_at": datetime.now(_KST).isoformat(), # KST +09:00 포함 } diff --git a/backend/app/api/endpoints/market_intelligence.py b/backend/app/api/endpoints/market_intelligence.py index 9b0fedb..0d41bcc 100644 --- a/backend/app/api/endpoints/market_intelligence.py +++ b/backend/app/api/endpoints/market_intelligence.py @@ -90,8 +90,20 @@ async def _get_playbook() -> dict: async def _get_news_sentiment(stock_code: Optional[str] = None) -> dict: """news_sentiment.get_market_news_score() 호출. 파일 없으면 빈 결과.""" try: - from app.services.news_sentiment import get_market_news_score # type: ignore - return await get_market_news_score(stock_code=stock_code) + from app.services.news_sentiment import ( # type: ignore + get_market_news_score, + get_stock_news_score, + ) + if stock_code: + score = float(await get_stock_news_score(stock_code)) + else: + score = float(await get_market_news_score()) + return { + "market_score": score, + "top_positive": [], + "top_negative": [], + "reason": None, + } except ImportError: return { "market_score": None, diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 7d01b48..a3a578b 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -32,6 +32,7 @@ from datetime import datetime, timedelta from app.core.timeutil import now_kst from app.core.log_throttle import warn_throttled # noqa: E402 +from app.core.background_tasks import spawn_tracked_v2 # noqa: E402 logger = logging.getLogger(__name__) @@ -79,6 +80,26 @@ _SEEDED_AT_ENTRY_REASON_PREFIX = "외부 유입 포지션 — 동기화 시 자 # (PENDING 을 넣으면 실현손익이 오염된다) 여기는 "매수 시도 흔적이 있는가"로 # 의미가 다르다. 같은 상수로 묶으면 한쪽을 고칠 때 반대쪽이 조용히 망가진다. _NON_FILL_BUY_STATUSES = ("FAILED",) +from app.core.trade_status import REALIZED_PNL_STATUSES # noqa: E402 (상태 집합 단일 출처) + +# ── 팬텀 ActiveTrade close 유예 (2026-10-09) ───────────────────────────── +# 매수/분할매도 직후 KIS 잔고·Portfolio 반영이 늦으면 held=0 으로 보여 갓 만든 행이 +# 즉시 closed 되고 'synced' 로 재시딩되어 remaining_pct·전략명을 잃었다. +_PHANTOM_CLOSE_GRACE_SEC = 300.0 # 행 생성/수정 후 이 시간 안에는 close 하지 않는다 +_PHANTOM_CLOSE_CONFIRM_SYNCS = 2 # 연속 미관측 횟수 +_PHANTOM_RECENT_TRADE_SEC = 1800.0 # 이 시간 내 체결(BUY/SELL)이 있으면 보류 +_PHANTOM_INHERIT_WINDOW_SEC = 1800.0 # 재시딩 시 승계 대상 closed 행의 최대 경과 +_PHANTOM_INHERIT_SELL_LOOKBACK_SEC = 120.0 # close 직전 이 시간 안의 SELL 도 그 close 의 매도로 본다 +_PHANTOM_INHERIT_MAX_ENTRY_DIVERGENCE = 0.03 # 옛 진입가-현재 평단 괴리가 이를 넘으면 다른 포지션으로 본다 +_INHERIT_ENTRY_REASON_PREFIX = "외부 유입 포지션 — 재시딩 승계: " # exit_service 시딩 표식 접두사와 동일 계열 +_phantom_miss_state: dict = {} # {stock_code: 연속 미관측 횟수} + + +def _phantom_state_reset(stock_code: str = None) -> None: + if stock_code is None: + _phantom_miss_state.clear() + else: + _phantom_miss_state.pop(stock_code, None) # ── 유입 잔고 강등(swing → external) 확정 규칙 ────────────────────────────── # [2026-09-30] 사용자 결정: "스윙으로 편입하는 건 없어야 해" → 승격뿐 아니라 강등도 @@ -645,11 +666,127 @@ def _should_correct_active_trade_quantity( return True +async def _recent_closed_non_dt_row_v2(db, stock_code: str, now=None): + """같은 종목에서 최근(_PHANTOM_INHERIT_WINDOW_SEC 이내) closed 된 비-DT ActiveTrade 중 최신 1행. 없으면/실패 시 None. + + 읽기 전용 SELECT (sync_portfolio 의 기존 트랜잭션 안). + """ + try: + from app.services.db_worker import not_dt_strategy_clause + _n = now or _get_kst_now() + # 오승계 방지: 전량 청산 후 다른 경로로 재편입한 포지션이 옛 단계를 물려받지 않도록 짧게. + _since = _n - timedelta(seconds=_PHANTOM_INHERIT_WINDOW_SEC) + r = await db.execute( + select(ActiveTrade).where( + ActiveTrade.stock_code == stock_code, + ActiveTrade.status == "closed", + ActiveTrade.updated_at >= _since, + not_dt_strategy_clause(ActiveTrade.strategy_name), + ).order_by(ActiveTrade.id.desc()).limit(1) + ) + return r.scalars().first() + except Exception as e: + logger.warning(f"[ActiveTrade] 최근 closed 행 조회 실패({stock_code}): {e}") + return None + + +async def _closed_row_was_real_exit_v2(db, row, now=None): + """closed 행이 실제 전량 청산(SELL 체결)으로 닫혔는가. True/False, 판정 불가면 None. + + 팬텀 close 는 SELL 없이 닫힌다(최근 체결이 있으면 close 자체가 유예됨). close 시각 + (updated_at) 직전 _PHANTOM_INHERIT_SELL_LOOKBACK_SEC 이후의 비-DT COMPLETED SELL 수량 + 합이 행 수량 이상이면 실제 전량 청산 → 승계 대상이 아니다. 읽기 전용 SELECT. + """ + try: + from app.services.db_worker import not_dt_strategy_clause + _closed_at = getattr(row, "updated_at", None) or getattr(row, "created_at", None) + _qty = int(getattr(row, "quantity", 0) or 0) + if _closed_at is None or _qty <= 0: + return None + _lo = _closed_at - timedelta(seconds=_PHANTOM_INHERIT_SELL_LOOKBACK_SEC) + r = await db.execute( + select(TradeHistory.executed_quantity, TradeHistory.order_quantity).where( + TradeHistory.stock_code == row.stock_code, + TradeHistory.order_type == "SELL", + TradeHistory.status == "COMPLETED", + TradeHistory.created_at >= _lo, + not_dt_strategy_clause(TradeHistory.strategy_type), + ) + ) + _sold = 0 + for _eq, _oq in r.all(): + _sold += int(_eq if _eq is not None else (_oq or 0)) + return _sold >= _qty + except Exception as e: + logger.warning(f"[ActiveTrade] closed 행 실청산 여부 조회 실패({getattr(row, 'stock_code', '?')}): {e}") + return None + + +async def _inheritable_closed_row_v3(db, stock_code: str, avg_price: float, now=None): + """재시딩 승계 대상 행. 최근 closed 비-DT 행 중 (a) 실제 전량 청산이 아니고(판정 불가도 제외) + (b) 옛 진입가가 현재 평단과 크게 다르지 않은 것만. 없으면 None.""" + _row = await _recent_closed_non_dt_row_v2(db, stock_code, now) + if _row is None: + return None + _real = await _closed_row_was_real_exit_v2(db, _row, now) + if _real is not False: + logger.warning( + f"[ActiveTrade] 재시딩 승계 건너뜀({stock_code}): 이전 행 id={_row.id} " + f"{'실제 전량 청산' if _real else '청산 여부 불명'} — 새 포지션으로 취급" + ) + return None + _ep = float(getattr(_row, "entry_price", 0) or 0) + _avg = float(avg_price or 0) + if _ep > 0 and _avg > 0 and abs(_ep - _avg) / _avg > _PHANTOM_INHERIT_MAX_ENTRY_DIVERGENCE: + logger.warning( + f"[ActiveTrade] 재시딩 승계 건너뜀({stock_code}): 옛 진입가 {_ep:,.0f} 와 평단 {_avg:,.0f} 괴리 " + f"> {_PHANTOM_INHERIT_MAX_ENTRY_DIVERGENCE:.0%} — 다른 포지션" + ) + return None + return _row + + +def _inherited_entry_reason_v2(reason: str) -> str: + """승계 행의 entry_reason 에 시딩 표식 접두사를 붙인다 — created_at 이 재시딩 시각이므로 + swing/exit_service._is_seeded_active_trade_v2 가 인식해 원 BUY 일자로 시계·승패를 보정하게 한다.""" + if reason.startswith(("동기화 유입", "외부 유입 포지션")): + return reason + return _INHERIT_ENTRY_REASON_PREFIX + reason + + +def _phantom_close_defer_reason(code, row, recent_trade_codes, now=None): + """보유 0 으로 보이는 단일 active 행을 지금 close 해도 되는가. 유예 사유 문자열 or None. + + recent_trade_codes=None 은 "최근 체결 조회 실패" → 확인 불가이므로 보류(fail-safe). + """ + if recent_trade_codes is None: + return "recent_trades_unknown" + if code in recent_trade_codes: + return "recent_trade" + _now = now or _get_kst_now() + _ts = getattr(row, "updated_at", None) or getattr(row, "created_at", None) + if _ts is not None: + try: + if (_now - _ts).total_seconds() < _PHANTOM_CLOSE_GRACE_SEC: + return "row_recent" + except Exception as e: # tz 혼재 등 — 판단 불가는 보류 + logger.warning(f"[ActiveTrade] 행 나이 계산 실패({code}): {e}") + return "row_age_unknown" + _n = _phantom_miss_state.get(code, 0) + 1 + _phantom_miss_state[code] = _n + if _n < _PHANTOM_CLOSE_CONFIRM_SYNCS: + return f"miss_{_n}" + return None + + def _reconcile_active_trade_quantities( active_rows_by_code: dict, held_qty: dict, strategy_by_code: dict, system_qty: dict = None, + phantom_guard: bool = False, + recent_trade_codes: set = None, + now: datetime = None, ) -> list: """active ActiveTrade 행을 실제 보유 수량에 맞춘다 (ORM 객체를 **제자리 수정**). @@ -678,7 +815,19 @@ def _reconcile_active_trade_quantities( for _code, _rows in (active_rows_by_code or {}).items(): _actual = int(held_qty.get(_code, 0) or 0) _tag = strategy_by_code.get(_code, "swing") + if _actual > 0: + _phantom_miss_state.pop(_code, None) if len(_rows) == 1 and _actual == 0: + if phantom_guard: + _defer = _phantom_close_defer_reason( + _code, _rows[0], recent_trade_codes, now + ) + if _defer: + actions.append({"action": "close_deferred", "stock_code": _code, + "from": int(_rows[0].quantity or 0), "to": 0, + "reason": _defer}) + continue + _phantom_miss_state.pop(_code, None) _rows[0].status = "closed" actions.append({"action": "closed", "stock_code": _code, "from": int(_rows[0].quantity or 0), "to": 0}) @@ -799,6 +948,15 @@ def _calculate_portfolio_values(holdings: list) -> dict: } +def _quantize_price_v2(value) -> Decimal: + """가격 반올림 정책 — 소수 2자리 ROUND_HALF_UP (DB Numeric(15,2) 와 동일).""" + from decimal import ROUND_HALF_UP, InvalidOperation + try: + return Decimal(str(value or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + except (InvalidOperation, ValueError, TypeError): + return Decimal("0") + + def _extract_balance_data(balance_data: dict) -> dict: """Extract cash and total assets from KIS API balance response. @@ -898,13 +1056,107 @@ def _extract_overseas_balance(overseas_balance_data: dict) -> dict: if isinstance(ovrs_out2, list): ovrs_out2 = ovrs_out2[0] if ovrs_out2 else {} # tot_evlu_amt: 해외주식 평가금액 합계 (원화) - overseas_total_krw = _pick_float(ovrs_out2, ["tot_evlu_amt", "frcr_evlu_tota"]) + # [2026-10-08] frcr_evlu_tota 는 **외화(USD)** 평가금액이라 원화 폴백으로 쓰면 단위가 섞인다 + # → 제거. 원화 값이 없으면 cache_summary 가 보유 USD × 환율로 환산한다. + overseas_total_krw = _pick_float(ovrs_out2, ["tot_evlu_amt"]) if overseas_total_krw > 0: logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원") return {"overseas_total_krw": overseas_total_krw} +# ── [2026-10-08] USD/KRW 환율 해석 ──────────────────────────────────────────── +# 이전엔 sync_portfolio 가 cache_summary(balance, overseas) 로 환율 인자를 넘기지 않아 항상 0 → +# 미국 보유 원화 환산이 0 이었다. 우선순위: KIS 응답에서 얻은 환율 → 마지막 성공값 → +# settings.US_DEFAULT_FX_RATE → (모두 없으면) 0 = 환산 생략 + exchange_rate_unavailable 플래그. +# 해외잔고(TTTS3012R) 응답에는 환율 필드가 없을 수 있어 실제로는 config 폴백이 흔하다. +_FX_RATE_SANE_MIN = 500.0 +_FX_RATE_SANE_MAX = 3000.0 +_last_good_fx_rate: float = 0.0 + + +def _fx_from_overseas_balance(overseas_balance_data: dict | None) -> float: + """해외잔고 응답에서 USD/KRW 환율 추출 (없으면 0). 순수 함수.""" + if not isinstance(overseas_balance_data, dict): + return 0.0 + out1 = overseas_balance_data.get("output1") + out2 = overseas_balance_data.get("output2") + if isinstance(out2, list): + out2 = out2[0] if out2 else {} + out2 = out2 if isinstance(out2, dict) else {} + rows = out1 if isinstance(out1, list) else [] + # 1) 명시적 환율 필드 + for src in [out2] + [r for r in rows if isinstance(r, dict)]: + v = _pick_float(src, ["bass_exrt", "frst_bltn_exrt", "exrt"]) + if v > 0: + return v + # 2) 원화 총평가 / 외화 총액 역산 (두 값이 모두 있을 때만) + tot_krw = _pick_float(out2, ["tot_evlu_amt"]) + usd = _pick_float(out2, ["frcr_evlu_tota", "usd_amt"]) # 첫 양수 1개(합산 시 이중 계상) + if tot_krw > 0 and usd > 0: + return tot_krw / usd + return 0.0 + + +def _resolve_fx_rate_v2(overseas_balance_data: dict | None) -> tuple[float, str]: + """(환율, 출처) — 출처: kis | last_good | config_default | unavailable.""" + global _last_good_fx_rate + from app.core.config import settings as _settings + v = _fx_from_overseas_balance(overseas_balance_data) + if _FX_RATE_SANE_MIN <= v <= _FX_RATE_SANE_MAX: + _last_good_fx_rate = float(v) + return float(v), "kis" + if v > 0: + logger.warning( + f"[FX] KIS 응답 환율 {v:,.4f} 이 정상 범위({_FX_RATE_SANE_MIN:,.0f}~{_FX_RATE_SANE_MAX:,.0f}) 밖 — 무시" + ) + if _last_good_fx_rate > 0: + return _last_good_fx_rate, "last_good" + cfg = float(getattr(_settings, "US_DEFAULT_FX_RATE", 0) or 0) + if cfg > 0: + return cfg, "config_default" + return 0.0, "unavailable" + + +def _cash_breakdown_v2(balance_data: dict | None, total_assets_domestic: float) -> dict: + """[2026-10-08] 총자산·예수금 기준 분해 (순수 함수). + + KIS 국내잔고 output2 의 항등식: tot_evlu_amt(총평가) = scts_evlu_amt(유가증권 평가) + + prvs_rcdl_excc_amt(가수도정산금액 = D+2 예수금). 화면의 '예수금'(available_cash)은 매수용 + **주문가능현금**(inquire-psbl-order ord_psbl_cash)이라 D+2 예수금과 다르다(미체결 매수 + 증거금·당일 매도대금 반영 차이) → '주식 + 예수금 ≠ 총자산' 의 원인. + + 반환 필드(summary 에 그대로 병합): + settlement_cash : D+2 예수금(prvs_rcdl_excc_amt) — 총자산을 구성하는 현금 기준 + orderable_cash : 주문가능현금(_ord_psbl_cash, 없으면 None) + deposit_total : 예수금총액(dnca_tot_amt, 참고용) + cash_basis_gap : settlement_cash − available_cash(화면 예수금) + total_assets_residual : tot_evlu_amt − (scts_evlu_amt + prvs_rcdl_excc_amt) — 0 이 정상 + cash_for_total_assets : 총자산(국내) − 주식평가(scts) — '주식 + 예수금 = 총자산' 이 정확히 + 성립하는 현금. 화면 예수금 표시는 이 값을 쓴다(주문가능은 별도 표기). + """ + out2 = {} + if isinstance(balance_data, dict) and balance_data.get("output2"): + o = balance_data["output2"] + out2 = (o[0] if isinstance(o, list) and o else o) if o else {} + out2 = out2 if isinstance(out2, dict) else {} + settlement = _pick_float(out2, ["prvs_rcdl_excc_amt"]) + scts = _pick_float(out2, ["scts_evlu_amt"]) + tot = _pick_float(out2, ["tot_evlu_amt"]) + ord_raw = out2.get("_ord_psbl_cash") + orderable = float(ord_raw) if ord_raw not in (None, "") else None + residual = (tot - scts - settlement) if tot > 0 else 0.0 + cash_for_total = (total_assets_domestic - scts) if (total_assets_domestic > 0 and scts >= 0 and tot > 0) else None + return { + "settlement_cash": settlement, + "orderable_cash": orderable, + "deposit_total": _pick_float(out2, ["dnca_tot_amt"]), + "total_assets_residual": residual, + "cash_for_total_assets": cash_for_total, + "total_assets_basis": "kis_tot_evlu_amt" if tot > 0 else "cash_plus_holdings", + } + + # 단순 인메모리 캐시 (프로세스 단위). 다중 프로세스 환경이면 Redis 등 필요. class PortfolioCache: def __init__(self): @@ -1092,6 +1344,10 @@ class PortfolioCache: if overseas_total_krw > 0: total_assets += overseas_total_krw + elif overseas_value_krw > 0: + # [2026-10-08] KIS 원화 총평가가 없으면 보유 USD × 환율 환산값을 더한다 + # (total_value 에는 이미 포함 — 화면의 주식+예수금과 총자산을 같은 범위로 맞춘다) + total_assets += overseas_value_krw # 5. Calculate daily profit/loss today_profit_loss = (total_assets - bfdy_total_assets) if bfdy_total_assets > 0 else 0.0 @@ -1126,6 +1382,70 @@ class PortfolioCache: async with self.summary_lock: self.summary = summary_data + async def cache_summary_v2( + self, + balance_data: dict = None, + overseas_balance_data: dict = None, + exchange_rate: float | None = None, + exchange_rate_source: str | None = None, + ): + """[2026-10-08] cache_summary + 환율 해석 + 현금 기준 분해 필드. + + exchange_rate 가 None 이면 `_resolve_fx_rate_v2` 로 정한다(마지막 성공값/설정값 폴백). + 0 이면 해외 원화 환산을 생략하고 exchange_rate_unavailable=True 로 표시한다. + """ + if exchange_rate is None: + exchange_rate, exchange_rate_source = _resolve_fx_rate_v2(overseas_balance_data) + exchange_rate = float(exchange_rate or 0) + # 설정 기본값(1350 등)은 추정치다 — 총자산(=4% 사이징·당일손익 기준)에 합산하면 + # 실환율과 어긋난 만큼 리스크 지표가 왜곡되고 화면은 확정값처럼 보인다. 합산에서 제외하고 + # (KIS 가 준 원화 총평가가 있으면 그것만 합산) 추정 환율은 별도 필드로만 노출한다. + _fx_estimate = 0.0 + if exchange_rate_source == "config_default" and exchange_rate > 0: + _fx_estimate = exchange_rate + exchange_rate = 0.0 + await self.cache_summary(balance_data, overseas_balance_data, exchange_rate) + + _has_overseas = any( + not _is_domestic(h.stock_code, getattr(h, "market", None)) + for h in (self.holdings or []) if (h.quantity or 0) > 0 + ) + _unavailable = exchange_rate <= 0 + if _unavailable and _has_overseas: + warn_throttled( + logger, "portfolio:fx_unavailable", + "[FX] USD/KRW 환율 없음(KIS·마지막 성공값·설정값 모두 0) — 해외 보유 원화 환산 생략", + ) + # _extract_balance_data 는 INFO 로그 3줄을 남기므로 재호출하지 않는다 — 원화 총평가는 + # _cash_breakdown_v2 가 같은 output2 에서 직접 읽는다. + _kis_total = 0.0 + if isinstance(balance_data, dict) and balance_data.get("output2"): + _o2 = balance_data["output2"] + _o2 = (_o2[0] if isinstance(_o2, list) and _o2 else _o2) or {} + _kis_total = _pick_float(_o2, ["tot_evlu_amt"]) if isinstance(_o2, dict) else 0.0 + extra = { + "exchange_rate_source": exchange_rate_source or ("unavailable" if _unavailable else "caller"), + "exchange_rate_unavailable": bool(_unavailable), + "fx_conversion_skipped": bool(_unavailable and _has_overseas), + } + if _fx_estimate > 0: + extra["exchange_rate_estimate"] = _fx_estimate + extra["exchange_rate_source"] = "config_default" + if balance_data: + extra.update(_cash_breakdown_v2(balance_data, _kis_total)) + if abs(float(extra.get("total_assets_residual") or 0)) > 1000: + warn_throttled( + logger, "portfolio:total_assets_residual", + f"[Portfolio] 총자산 항등식 불일치: tot_evlu_amt − (scts_evlu_amt + prvs_rcdl_excc_amt) = " + f"{float(extra['total_assets_residual']):,.0f}원 — KIS 필드 정의 확인 필요", + ) + async with self.summary_lock: + if self.summary is not None: + self.summary.update(extra) + if "settlement_cash" in extra: + _avail = float(self.summary.get("available_cash") or 0) + self.summary["cash_basis_gap"] = float(extra.get("settlement_cash") or 0) - _avail + portfolio_cache = PortfolioCache() from pydantic import BaseModel @@ -1143,11 +1463,131 @@ class PortfolioResponse(BaseModel): profit_loss_rate: Optional[Decimal] = None market: str = "KRX" strategy_type: str = "swing" + # [2026-10-08] 전략별 분해 (기존 필드 의미 불변 — quantity/average_price 는 계좌 합산·혼합 평단). + # `_annotate_strategy_breakdown_v2` 가 채운다. 정의는 그 함수 docstring 참조. + swing_quantity: Optional[int] = None + swing_avg_price: Optional[float] = None + swing_avg_price_basis: Optional[str] = None + swing_profit_loss: Optional[float] = None + swing_profit_loss_rate: Optional[float] = None + dt_quantity: Optional[int] = None + dt_avg_price: Optional[float] = None + avg_price_mixed: Optional[bool] = None class Config: from_attributes = True +_BREAKDOWN_FIELDS: tuple[str, ...] = ( + "swing_quantity", "swing_avg_price", "swing_avg_price_basis", + "swing_profit_loss", "swing_profit_loss_rate", + "dt_quantity", "dt_avg_price", "avg_price_mixed", +) + + +def _round_price_v2(value) -> Optional[float]: + """API 응답 가격 반올림 정책: 소수 2자리(DB Numeric(15,2) 와 동일). 0/None → None.""" + try: + v = float(value or 0) + except (TypeError, ValueError): + return None + return round(v, 2) if v > 0 else None + + +def _strategy_breakdown_v2(holding, swing_entry_price, dt_pos: dict | None) -> dict: + """[2026-10-08] 보유 행 1개의 전략별 수량·평단 (순수 함수). + + Portfolio 행은 stock_code UNIQUE 라 스윙+DT 가 합산돼 있고 평단도 혼합된다(§3.5). + dt_quantity / dt_avg_price : DT 엔진 인메모리 포지션(수량·체결가 보정된 진입가) + swing_quantity : 행 수량 − DT 수량 (DT 아닌 보유분: swing/external/manual 포함) + swing_avg_price : 스윙 ActiveTrade.entry_price (resolve_swing_entry_price_v2 — + 괴리 30% 초과면 보수적 max, 없으면 Portfolio 평단 폴백) + swing_avg_price_basis : active_trade | conservative_max | portfolio_avg | none + swing_profit_loss(_rate) : (현재가 − swing_avg_price) × swing_quantity, 비용 전 + avg_price_mixed : DT 보유가 있거나 스윙 진입가가 Portfolio 평단과 다르면 True + """ + from app.services.swing.position_manager import resolve_swing_entry_price_v2 + qty = int(getattr(holding, "quantity", 0) or 0) + dt_qty = int((dt_pos or {}).get("quantity", 0) or 0) + dt_qty = max(0, min(dt_qty, qty)) + dt_avg = _round_price_v2((dt_pos or {}).get("entry_price")) if dt_qty > 0 else None + swing_qty = max(0, qty - dt_qty) + pa = float(getattr(holding, "average_price", 0) or 0) + cur = float(getattr(holding, "current_price", 0) or 0) + swing_avg = None + basis = "none" + pl = None + pl_rate = None + if swing_qty > 0: + if dt_qty > 0 and not swing_entry_price: + # DT 공존 + 스윙 진입가 출처 없음 → 혼합 평단에서 DT 분을 빼서 역산 + _rest = pa * qty - float((dt_pos or {}).get("entry_price") or 0) * dt_qty + _derived = _rest / swing_qty if swing_qty > 0 else 0.0 + if _derived > 0: + swing_avg, basis = _derived, "derived_from_mixed" + if swing_avg is None: + swing_avg, basis = resolve_swing_entry_price_v2( + swing_entry_price, pa, getattr(holding, "stock_code", ""), + ) + swing_avg = _round_price_v2(swing_avg) + if swing_avg and cur > 0: + pl = round((cur - swing_avg) * swing_qty, 2) + pl_rate = round((cur / swing_avg - 1.0) * 100, 4) + mixed = dt_qty > 0 or (swing_avg is not None and pa > 0 and abs(swing_avg - round(pa, 2)) >= 0.01) + return { + "swing_quantity": swing_qty, + "swing_avg_price": swing_avg, + "swing_avg_price_basis": basis, + "swing_profit_loss": pl, + "swing_profit_loss_rate": pl_rate, + "dt_quantity": dt_qty, + "dt_avg_price": dt_avg, + "avg_price_mixed": bool(mixed), + } + + +async def _annotate_strategy_breakdown_v2(db: AsyncSession, holdings: list) -> list: + """보유 ORM 객체에 전략별 분해 필드를 **비매핑 속성**으로 붙인다(DB 쓰기 없음). + + API 응답 직렬화(PortfolioResponse / jsonable_encoder)가 이 속성을 그대로 내보낸다. + 조회 실패 시 warning 후 원본 그대로 반환(화면 표시용 — 매매 판정 경로 아님). + """ + if not holdings: + return holdings + try: + from app.services.db_worker import not_dt_strategy_clause + codes = [h.stock_code for h in holdings] + rows = (await db.execute( + select(ActiveTrade.stock_code, ActiveTrade.entry_price, ActiveTrade.id) + .where( + ActiveTrade.status == "active", + ActiveTrade.stock_code.in_(codes), + not_dt_strategy_clause(ActiveTrade.strategy_name), + ) + .order_by(ActiveTrade.id.desc()) + )).all() + swing_entry: dict = {} + for code, entry, _id in rows: + swing_entry.setdefault(code, entry) # 최신 행 우선(get_swing_active_trade 와 동일) + except Exception as e: + warn_throttled(logger, "portfolio:breakdown", f"[Portfolio] 전략별 평단 조회 실패 — 분해 필드 생략: {e}") + return holdings + try: + from app.services.daytrading.position_manager import day_trading_state as _dts + dt_positions = dict(_dts.positions) + except Exception as e: + warn_throttled(logger, "portfolio:breakdown_dt", f"[Portfolio] DT 인메모리 조회 실패 — DT 0 으로 표시: {e}") + dt_positions = {} + for h in holdings: + try: + info = _strategy_breakdown_v2(h, swing_entry.get(h.stock_code), dt_positions.get(h.stock_code)) + for k, v in info.items(): + setattr(h, k, v) + except Exception as e: + warn_throttled(logger, "portfolio:breakdown_row", f"[Portfolio] {h.stock_code} 전략별 분해 실패: {e}") + return holdings + + async def _get_latest_executed_trade_id(db: AsyncSession) -> int | None: try: result = await db.execute( @@ -1170,7 +1610,9 @@ async def get_portfolio(db: AsyncSession = Depends(get_db)): if portfolio_cache.holdings and not portfolio_cache.should_sync(): logger.debug("Portfolio cache hit (holdings)") # 수량 0인 종목 필터링 (안전장치) - return [h for h in portfolio_cache.holdings if h.quantity > 0] + return await _annotate_strategy_breakdown_v2( + db, [h for h in portfolio_cache.holdings if h.quantity > 0] + ) # 필요하면 동기화 시도 try: await sync_portfolio(db, force=False) @@ -1178,7 +1620,9 @@ async def get_portfolio(db: AsyncSession = Depends(get_db)): # 강제 동기화가 아닌 경우에는 오류를 사용자에게 전파하지 않고 캐시/빈 목록 반환 logger.warning("get_portfolio: sync failed, returning cached holdings") # 수량 0인 종목 필터링 (안전장치) - return [h for h in portfolio_cache.holdings if h.quantity > 0] + return await _annotate_strategy_breakdown_v2( + db, [h for h in portfolio_cache.holdings if h.quantity > 0] + ) @router.get("/sync") @@ -1258,7 +1702,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # 현금 계산이 영영 막히지 않도록 holdings 기반 부분값(available_cash=0)이라도 채워둔다. if portfolio_cache.summary is None: try: - await portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 반영 + await portfolio_cache.cache_summary_v2() # available_cash=0이지만 holdings는 즉시 반영 logger.info("📦 summary 최초 미존재 — holdings 기반 부분값으로 채움 (available_cash=0)") except Exception as summary_err: logger.warning(f"⚠️ 부분 summary 생성 실패 (summary는 None 유지): {summary_err}") @@ -1369,6 +1813,34 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # db_worker 큐 적체 여부 — 강등 보류 판단(사이클당 1회 조회). _queue_busy = _db_queue_busy() + # 최근 체결 종목(팬텀 ActiveTrade close 유예용) — 조회 실패 시 None(= close 보류). + _recent_trade_codes = None + try: + _rt_since = now - timedelta(seconds=_PHANTOM_RECENT_TRADE_SEC) + _rt_rows = await db.execute( + select(TradeHistory.stock_code).where( + TradeHistory.executed_at >= _rt_since, + TradeHistory.status != "FAILED", + ).distinct() + ) + _recent_trade_codes = {c for c in _rt_rows.scalars().all() if c} + except Exception as _rt_err: + logger.warning(f"최근 체결 조회 실패 — ActiveTrade close 보류: {_rt_err}") + + # DT active ActiveTrade 종목(DB) — 'daytrading' 태그 해제 판단 근거. 실패 시 None(해제 안 함). + _dt_db_codes = None + try: + from app.services.db_worker import DT_STRATEGY_NAMES as _DT_NAMES + _dtdb = await db.execute( + select(ActiveTrade.stock_code).where( + ActiveTrade.status == "active", + ActiveTrade.strategy_name.in_(list(_DT_NAMES)), + ) + ) + _dt_db_codes = {c for c in _dtdb.scalars().all() if c} + except Exception as _dtdb_err: + logger.warning(f"DT ActiveTrade 조회 실패 — daytrading 태그 해제 보류: {_dtdb_err}") + def _classify_durable(_code: str, _qty: int) -> str: """레지스트리를 **제외한** 근거만으로 판정 — 승격 판정 전용 (M-2). @@ -1454,7 +1926,10 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity") continue - average_price = Decimal(str(item.get("pchs_avg_pric", 0))) + # [2026-10-08] KIS pchs_avg_pric 는 소수 4자리("67323.0769")다. DB 컬럼은 + # Numeric(15,2) 라 커밋 후엔 2자리인데 캐시 ORM 객체는 원문을 들고 있어 화면에 + # 67,323.076 처럼 보였다 → 저장 규약(소수 2자리, 반올림)으로 맞춘다. + average_price = _quantize_price_v2(item.get("pchs_avg_pric", 0)) # 실시간 현재가 (병렬 조회 결과 참조) price_resp = _realtime_price_map.get(stock_code) @@ -1495,6 +1970,21 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # 전량이 DT 포지션일 때만 태깅(혼재 시 스윙 청산 대상 유지) if stock_code in _dt_codes and quantity <= _dt_qty.get(stock_code, 0): h.strategy_type = "daytrading" + elif ( + (h.strategy_type or "swing") == "daytrading" + and stock_code not in _dt_codes + and _dt_db_codes is not None + and stock_code not in _dt_db_codes + ): + # DT 인메모리·DB active 모두 없는데 태그만 남았다(비-DT 잔량/유입 잔존). + # 'daytrading' 이면 스윙 청산·DT 고아 청산·추천 매도 모두에서 빠져 + # 손절이 무력화된다(105560 KB금융). durable 근거로 재분류한다. + _re = _classify_durable(stock_code, quantity) + logger.warning( + f"🏷️ daytrading 태그 해제: {stock_name}({stock_code}) {quantity}주 " + f"daytrading → {_re} (DT 포지션 없음)" + ) + h.strategy_type = _re elif ( (h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE and _classify_durable(stock_code, quantity) == "swing" @@ -1575,7 +2065,10 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) stock_name = item.get("ovrs_item_name", stock_code) exchange_code = item.get("ovrs_excg_cd", "NASD") - average_price = Decimal(str(item.get("pchs_avg_pric", 0))) + # [2026-10-08] KIS pchs_avg_pric 는 소수 4자리("67323.0769")다. DB 컬럼은 + # Numeric(15,2) 라 커밋 후엔 2자리인데 캐시 ORM 객체는 원문을 들고 있어 화면에 + # 67,323.076 처럼 보였다 → 저장 규약(소수 2자리, 반올림)으로 맞춘다. + average_price = _quantize_price_v2(item.get("pchs_avg_pric", 0)) # 실시간 현재가 price_resp = _ovrs_price_map.get(stock_code) @@ -1691,12 +2184,20 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) h.stock_code: (h.strategy_type or "swing") for h in new_holdings } for _act in _reconcile_active_trade_quantities( - _by_code, _held, _strategy_by_code, _system_qty_durable + _by_code, _held, _strategy_by_code, _system_qty_durable, + phantom_guard=True, + recent_trade_codes=_recent_trade_codes, + now=now, ): if _act["action"] == "closed": logger.info( f"🧹 ActiveTrade 정리: {_act['stock_code']} 보유 없음 → closed" ) + elif _act["action"] == "close_deferred": + logger.info( + f"⏳ ActiveTrade 정리 유예: {_act['stock_code']} 보유 0 관측 " + f"({_act.get('reason')})" + ) elif _act["action"] == "corrected": logger.info( f"🧹 ActiveTrade 잔량 교정: {_act['stock_code']} " @@ -1753,6 +2254,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) if _seed_name == "swing": _seed_name = "synced" # DLQ 직렬화(json) 대비 — Decimal 대신 순수 float/int/str 만 담는다. + _inh = await _inheritable_closed_row_v3(db, _h.stock_code, _avg, now) _seed_payload = { "stock_code": _h.stock_code, "stock_name": _h.stock_name, @@ -1768,6 +2270,33 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) "currency_code": _h.currency_code or "KRW", "exchange_code": _h.exchange_code, } + if _inh is not None: + # 방금 닫힌 비-DT 행의 분할 단계·전략명·진입근거를 승계 — 안 하면 + # remaining_pct 가 1.0 으로 되돌아 1차 익절이 재실행되고 모멘텀/눌림목이 + # 평균회귀 청산을 탄다. (created_at 은 payload 로 못 넣어 승계 불가 → entry_reason 시딩 접두사로 원 BUY 일자 보정) + _inh_rem = float(_inh.remaining_pct if _inh.remaining_pct is not None else 1.0) + _seed_payload["remaining_pct"] = max(0.0, min(1.0, _inh_rem)) + if _inh.strategy_name: + _seed_payload["strategy_name"] = _inh.strategy_name + if _inh.entry_reason: + _seed_payload["entry_reason"] = _inherited_entry_reason_v2(_inh.entry_reason) + _inh_hi = float(_inh.highest_since_entry or 0) + if _inh_hi > _seed_payload["highest_since_entry"]: + _seed_payload["highest_since_entry"] = _inh_hi + _inh_sl = float(_inh.stop_loss or 0) + if _inh_sl > 0: + _seed_payload["stop_loss"] = _inh_sl + _inh_tp = float(_inh.take_profit or 0) + if _inh_tp > 0: + _seed_payload["take_profit"] = _inh_tp + _inh_ep = float(_inh.entry_price or 0) + if _inh_ep > 0: + _seed_payload["entry_price"] = _inh_ep + logger.warning( + f"♻️ ActiveTrade 재시딩 승계: {_h.stock_code} 이전 행 id={_inh.id} " + f"remaining={_seed_payload['remaining_pct']} " + f"strategy={_seed_payload['strategy_name']}" + ) _seed_uid = getattr(_h, "user_id", None) if _seed_uid is not None: _seed_payload["user_id"] = int(_seed_uid) @@ -1832,7 +2361,8 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) ) portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0] - await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달 + # [2026-10-08] v2 — 환율(0 금지: 마지막 성공값/설정값 폴백) + 현금 기준 분해 필드 + await portfolio_cache.cache_summary_v2(balance_data, overseas_balance_data) # 국내·해외 잔고 전달 portfolio_cache.last_synced = datetime.now() portfolio_cache.change_flag = False portfolio_cache.last_trade_id = await _get_latest_executed_trade_id(db) @@ -1887,7 +2417,7 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)): realized_result = await db.execute( select(func.sum(TradeHistory.net_profit_loss)).where( TradeHistory.order_type == "SELL", - TradeHistory.status.in_(["EXECUTED", "COMPLETED"]), + TradeHistory.status.in_(list(REALIZED_PNL_STATUSES)), TradeHistory.net_profit_loss.isnot(None), ) ) @@ -1945,6 +2475,13 @@ def _build_combined_resp() -> dict: @router.get("/combined") async def get_portfolio_combined(db: AsyncSession = Depends(get_db)): + """보유종목 + 요약 — [2026-10-08] holdings 에 전략별 분해 필드를 붙인다(_impl 은 기존 본체).""" + resp = await _get_portfolio_combined_impl(db) + await _annotate_strategy_breakdown_v2(db, resp.get("holdings") or []) + return resp + + +async def _get_portfolio_combined_impl(db: AsyncSession): """보유종목 목록 + 요약을 반환. 응답 우선순위: @@ -1965,7 +2502,7 @@ async def get_portfolio_combined(db: AsyncSession = Depends(get_db)): # ② 캐시 만료, 매매 변경 없음 → stale 데이터 즉시 반환 + 백그라운드 갱신 if portfolio_cache.holdings and portfolio_cache.summary and not portfolio_cache.change_flag: logger.debug("Portfolio cache stale (no trade) → returning cached, syncing in background") - asyncio.create_task(_sync_portfolio_background()) + spawn_tracked_v2(_sync_portfolio_background()) return _build_combined_resp() # ③ 캐시 비어있음 (서버 재시작) → DB에서 즉시 로드 + 백그라운드 KIS 갱신 @@ -1974,9 +2511,9 @@ async def get_portfolio_combined(db: AsyncSession = Depends(get_db)): db_holdings = list(result.scalars().all()) if db_holdings: portfolio_cache.holdings = db_holdings - await portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 표시 + await portfolio_cache.cache_summary_v2() # available_cash=0이지만 holdings는 즉시 표시 logger.info(f"Portfolio cold-start: DB {len(db_holdings)}종목 즉시 반환, KIS 갱신 백그라운드 시작") - asyncio.create_task(_sync_portfolio_background()) + spawn_tracked_v2(_sync_portfolio_background()) return _build_combined_resp() # ④ 매매 발생 또는 DB도 비어있음 → KIS API 동기 호출 @@ -2317,7 +2854,7 @@ async def check_and_execute_exit_trades(db: AsyncSession): logger.info(f"자동 매도 완료: {stock_name} {sell_quantity}주 @ {latest_price:,.0f}원") # 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소 - asyncio.create_task(_create_journal_background( + spawn_tracked_v2(_create_journal_background( trade_id=None, order_id=_order_id, stock_code=stock_code, @@ -2530,7 +3067,9 @@ async def sell_stock( "order_type": "SELL", "order_price": float(price) if price else None, "order_quantity": quantity, - "executed_price": float(executed_price), + # [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다 — 현재가/평단을 체결가로 + # 쓰지 않는다. total_amount·손익은 확인 전 추정치. + "executed_price": None, "executed_quantity": quantity, "total_amount": float(executed_price * quantity), "status": "COMPLETED", @@ -2615,7 +3154,7 @@ async def sell_stock( logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)") # 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소 - asyncio.create_task(_create_journal_background( + spawn_tracked_v2(_create_journal_background( trade_id=None, order_id=order_result.get('output', {}).get('ODNO'), stock_code=stock_code, diff --git a/backend/app/api/endpoints/portfolio_v2.py b/backend/app/api/endpoints/portfolio_v2.py index 6a1198a..1bf9004 100644 --- a/backend/app/api/endpoints/portfolio_v2.py +++ b/backend/app/api/endpoints/portfolio_v2.py @@ -48,7 +48,7 @@ from app.services.manual_sell_service import ( ) from app.services.tenant.context import UserContext from app.services.tenant.registry import get_user_context, user_context_registry -from app.services.trade_executor import safe_place_order_v2 +from app.services.trade_executor import _is_order_outcome_unknown_v2, safe_place_order_v2 from app.services.utils import _is_domestic from app.api.endpoints.manual_buy import BuyRequest @@ -376,6 +376,35 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess ctx, stock_code=stock_code, order_type="BUY", quantity=quantity, price=request.price, is_domestic=True, ) + # [R7 2026-10-10] manual_buy R3 와 동일: 전송 후 결과 불명은 접수됐을 수 있다 — 쿨다운 + PENDING. + if _is_order_outcome_unknown_v2(order_result): + _last_done_v2[(ctx.user_id, stock_code)] = time.monotonic() + logger.error( + f"[MANUAL_BUY v2] user_id={ctx.user_id} {stock_code} 주문 결과 불명 — " + f"쿨다운 {int(_DUP_COOLDOWN_SEC)}초, PENDING 기록. 포트폴리오 확인 전 재시도 금지" + ) + _unk = { + "stock_code": stock_code, + "stock_name": stock_name, + "order_type": "BUY", + "order_price": float(request.price) if request.price else None, + "order_quantity": quantity, + "executed_price": None, + "executed_quantity": 0, + "total_amount": float(order_price * quantity), + "status": "PENDING", + "order_id": None, + "message": "수동 매수 결과 불명 (v2, 접수 여부 미확인 — 포트폴리오 확인 필요)", + "strategy_type": "manual", + "created_at": _kst_now(), + } + if not ctx.is_legacy: + _unk["user_id"] = ctx.user_id + await enqueue_trade_history(_unk) + raise HTTPException( + status_code=504, + detail="매수 주문 결과 불명 — 접수·체결됐을 수 있습니다. 포트폴리오 확인 후 재시도하세요", + ) if not order_result or order_result.get("rt_cd") != "0": msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패" raise HTTPException(status_code=500, detail=f"매수 주문 실패: {msg}") @@ -385,6 +414,8 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess from app.services.system_buy_registry import note_system_buy note_system_buy(stock_code, quantity) + # [R7] 접수(rt_cd 0)≠체결. 지정가는 PENDING·체결 0 으로 기록하고 Portfolio 선생성을 생략한다. + is_limit = bool(request.price) now = _kst_now() trade_history_payload = { "stock_code": stock_code, @@ -392,15 +423,17 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess "order_type": "BUY", "order_price": float(request.price) if request.price else None, "order_quantity": quantity, - "executed_price": float(order_price), - "executed_quantity": quantity, + # [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다 — 주문 기준가를 체결가로 쓰지 않는다. + # total_amount 는 확인 전 추정치. + "executed_price": None, + "executed_quantity": 0 if is_limit else quantity, "total_amount": float(order_price * quantity), - "status": "COMPLETED", + "status": "PENDING" if is_limit else "COMPLETED", "order_id": order_id, "message": "수동 매수 (v2, 사용자 스코프)", "strategy_type": "manual", "created_at": now, - "executed_at": now, + "executed_at": None if is_limit else now, } # M3: legacy 컨텍스트는 user_id를 payload에 넣지 않는다 — 스윙/데이트레이딩 등 # 기존 레거시 쓰기 경로가 전부 user_id 없이 쓰므로(NULL), 정합을 맞춰야 @@ -408,7 +441,7 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess if not ctx.is_legacy: trade_history_payload["user_id"] = ctx.user_id await enqueue_trade_history(trade_history_payload) - if holding is None: + if holding is None and not is_limit: portfolio_payload = { "stock_code": stock_code, "stock_name": stock_name, @@ -485,6 +518,32 @@ async def sell_stock_v2( order_result = await safe_place_order_v2( ctx, stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, is_domestic=True, ) + # [R7 2026-10-10] 결과 불명 SELL: 체결 여부를 모르므로 원장·보유 상태를 바꾸지 않고 + # PENDING 만 남긴다(잘못 차감하면 재매도 과다/추적 상실). 잔고 sync 가 보정한다. + if _is_order_outcome_unknown_v2(order_result): + logger.error(f"[SELL v2] user_id={ctx.user_id} {stock_code} 주문 결과 불명 — PENDING 기록, 상태 미변경") + _unk = { + "stock_code": stock_code, + "stock_name": stock_name, + "order_type": "SELL", + "order_price": float(price) if price else None, + "order_quantity": quantity, + "executed_price": None, + "executed_quantity": 0, + "total_amount": float((price or float(holding.current_price or 0)) * quantity), + "status": "PENDING", + "order_id": None, + "message": "수동 매도 결과 불명 (v2, 접수 여부 미확인 — 포트폴리오 확인 필요)", + "strategy_type": "manual", + "created_at": _kst_now(), + } + if not ctx.is_legacy: + _unk["user_id"] = ctx.user_id + await enqueue_trade_history(_unk) + raise HTTPException( + status_code=504, + detail="매도 주문 결과 불명 — 접수·체결됐을 수 있습니다. 포트폴리오 확인 후 재시도하세요", + ) if not order_result or order_result.get("rt_cd") != "0": error_msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패" # order_lock_busy = 자동 청산 등 다른 경로가 같은 종목을 주문 중 → 재시도 가능(409) @@ -495,28 +554,42 @@ async def sell_stock_v2( profit_loss = (executed_price - float(average_price or 0)) * quantity profit_loss_rate = ((executed_price / float(average_price)) - 1) * 100 if average_price else 0.0 + # [R7] 접수≠체결: 지정가 매도는 PENDING·체결 0, 보유/ActiveTrade/연패 상태를 건드리지 않는다 + # (미체결인데 차감하면 손절·청산 추적이 끊긴다). 체결 후 sync 가 반영한다. + is_limit_sell = bool(price) sell_history_payload = { "stock_code": stock_code, "stock_name": stock_name, "order_type": "SELL", "order_price": float(price) if price else None, "order_quantity": quantity, - "executed_price": float(executed_price), - "executed_quantity": quantity, + # [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다. 금액·손익은 확인 전 추정치. + "executed_price": None, + "executed_quantity": 0 if is_limit_sell else quantity, "total_amount": float(executed_price * quantity), - "status": "COMPLETED", + "status": "PENDING" if is_limit_sell else "COMPLETED", "order_id": (order_result.get("output") or {}).get("ODNO"), "message": f"수동 청산 (v2): 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)", "exit_reason": f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)", - "net_profit_loss": profit_loss, - "profit_loss_rate": profit_loss_rate, + "net_profit_loss": None if is_limit_sell else profit_loss, + "profit_loss_rate": None if is_limit_sell else profit_loss_rate, "strategy_type": "manual", "created_at": _kst_now(), - "executed_at": _kst_now(), + "executed_at": None if is_limit_sell else _kst_now(), } if not ctx.is_legacy: sell_history_payload["user_id"] = ctx.user_id await enqueue_trade_history(sell_history_payload) + if is_limit_sell: + logger.info(f"[SELL v2] user_id={ctx.user_id} {stock_code} 지정가 매도 접수 — 체결 확인 전 상태 미변경") + return { + "success": True, + "message": f"{stock_name} {quantity}주 지정가 매도 주문 접수 (체결 대기)", + "order_id": (order_result.get("output") or {}).get("ODNO"), + "profit_loss": 0.0, + "profit_loss_rate": 0.0, + "pending": True, + } # [M-5 2026-10-02] ActiveTrade·Portfolio·DT 인메모리 반영. # 기존: (1) 보유 행의 strategy_type 한 가지로만 close/upsert → 합산 행(스윙+DT)에서 diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 15f9202..24d86f7 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Depends from typing import List, Optional from datetime import datetime, date, timedelta import asyncio +import re import logging from collections import namedtuple @@ -39,6 +40,228 @@ def _today_kst() -> date: return datetime.now(_KST).date() +def _row_price_date_v2(stock) -> tuple: + """DB 행의 (price_date 'YYYYMMDD', stale 여부). stock_analysis 에는 price_date 컬럼이 없다. + + 장전(09:00 이전) 갱신 행은 차트 마지막 봉이 전일이라 analysis_date 를 가격일로 쓰면 거짓이다. + 갱신 시각(KST naive)이 analysis_date 당일 09:00 이전이면 가격일 미상("")+stale=True. + """ + ad = stock.analysis_date + if isinstance(ad, datetime): + ad = ad.date() + ts = getattr(stock, "updated_at", None) + if isinstance(ts, datetime) and ts < datetime(ad.year, ad.month, ad.day, 9, 0): + return "", True + return ad.strftime("%Y%m%d"), False + + +def _persistable_items_v2(items, today: date) -> list: + """분석 결과 중 DB(analysis_date=오늘)에 저장해도 되는 것만 남긴다. + + price_date 가 오늘이 아닌(장전 전일 봉 등) 결과를 오늘 날짜로 UPSERT 하면 소비처 + (analysis_date >= today 후보 쿼리)가 전일 데이터를 오늘 것으로 읽는다. 가격일 미상("")은 유지. + """ + ts = today.strftime("%Y%m%d") + out = [] + for it in items: + pd_ = str(getattr(it[2], "price_date", "") or "") + if pd_ and pd_ != ts: + continue + out.append(it) + return out + + +# ── 스캐너 표시용 매수 차단 판정 (2026-10-08) ──────────────────────────────── +# 신호 문자열에 이미 "…(거래량부족-차단)", "이격도 과열… - 매수금지", "급등과열: …" 처럼 +# 차단 사유가 섞여 있는데 화면은 이를 구분하지 못해 '추천'과 '차단'이 같은 줄에 표시됐다. +_BLOCK_MARKERS = ("차단", "매수금지", "급등과열", "이격도 과열") + + +def _block_reasons_v2(signals) -> list: + out = [] + for sig in (signals or []): + text = str(sig) + if any(m in text for m in _BLOCK_MARKERS): + out.append(text) + return out + + +_VOL_RATIO_SIGNAL_RE = re.compile(r"거래량 실린 상승 \(([0-9]+(?:\.[0-9]+)?)배\)") + + +def _stock_volume_ratio_v2(stock): + """종목의 거래량 배수(20일 평균 대비). 없으면 None. + + StockRecommendation 에는 volume_ratio 필드가 없으므로(market_analyzer 소유), + 1) 속성이 있으면 사용, 2) 없으면 market_analyzer 가 남기는 신호 문자열 + '거래량 실린 상승 (N.N배)' 에서 복원한다. 이 신호는 배수>=1.5 & 상승일 때만 생성되므로 + 복원 불가면 None(= 배수 미확인, 호출부 fail-closed). 장중 부분거래량은 배수를 낮게 만들 뿐이라 + 보수적 방향으로만 틀린다. + """ + ratio = getattr(stock, "volume_ratio", None) + if ratio is not None: + return ratio + for sig in (getattr(stock, "signals", None) or []): + m = _VOL_RATIO_SIGNAL_RE.search(str(sig)) + if m: + return float(m.group(1)) + return None + + +def _generic_rule_matches_v2(rule, stock, conditions: dict) -> bool: + """realtime 빌더 else 분기(비-MEAN_REVERSION 규칙)의 후보 판정. + + [하드닝 R1] 이전에는 rule_type 을 가리지 않아 SELL 규칙까지 rule_id 가 붙은 매수 후보가 + 되었고, trade_executor 의 has_rule 강제매수가 '거래량부족-차단' 등 차단 신호를 덮어썼다. + - SELL(및 EXIT/STOP 계열) 규칙은 매수 후보에서 제외 + - 차단 신호(_block_reasons_v2)가 있는 종목은 규칙 후보에서 제외 + RSI 임계(기본 40)는 기존 값을 유지한다(변경은 백테스트 필요). + """ + if str(getattr(rule, "rule_type", "") or "").upper() in ("SELL", "EXIT", "STOP_LOSS", "TAKE_PROFIT"): + return False + if _block_reasons_v2(getattr(stock, "signals", None)): + return False + conditions = conditions or {} + has_rsi_key = ("rsi_below" in conditions) or ("rsi_threshold" in conditions) + has_vol_key = "volume_above_average" in conditions + if conditions and not (has_rsi_key or has_vol_key): + # 해석 가능한 조건 키가 하나도 없으면 규칙 의도를 알 수 없다 -> fail-closed + return False + if stock.rsi is None: + return False + # rsi_below(엄격 미만) / rsi_threshold(이하, 기존 의미). 둘 다 있으면 더 엄격한 쪽. + if has_rsi_key: + try: + if "rsi_below" in conditions and not (stock.rsi < float(conditions["rsi_below"])): + return False + if "rsi_threshold" in conditions and not (stock.rsi <= float(conditions["rsi_threshold"])): + return False + except (TypeError, ValueError): + return False + elif not (stock.rsi <= 40): # RSI 키가 없는 조건의 기존 기본 동작 유지 + return False + if has_vol_key: + # 거래량 배수를 알 수 없으면(필드 미노출) 통과시키지 않는다(fail-closed). + ratio = _stock_volume_ratio_v2(stock) + try: + if ratio is None or not (float(ratio) >= float(conditions["volume_above_average"])): + return False + except (TypeError, ValueError): + return False + return True + + +# trade_executor 의 'ML HOLD(conf≥0.6) → 매수 차단' 게이트와 같은 신뢰도 기준(리터럴 단일화). +_ML_HOLD_BLOCK_CONF_V2 = 0.6 + + +def _ml_block_reasons_v2(ml_signal, ml_confidence) -> list: + """trade_executor 실제 매수 ML 게이트와 같은 정의의 ML 차단 사유. + + - ML HOLD & 신뢰도 ≥ 0.6 & ML_ENSEMBLE_ENABLED → 매수 차단('ML HOLD(conf≥0.6)') + - ML SELL 은 ML_SELL_SIGNAL_ENABLED=True 일 때만 차단 사유(False 면 trade_executor 가 + 매수를 막지 않으므로 사유에 넣지 않는다 — 참고 정보는 ml_signal 필드로 노출). + """ + sig = str(ml_signal or "").upper() + reasons = [] + try: + conf = float(ml_confidence or 0) + except (TypeError, ValueError): + conf = 0.0 + if sig == "HOLD" and settings.ML_ENSEMBLE_ENABLED and conf >= _ML_HOLD_BLOCK_CONF_V2: + reasons.append(f"ML HOLD(conf≥{_ML_HOLD_BLOCK_CONF_V2:g})") + if sig == "SELL" and settings.ML_SELL_SIGNAL_ENABLED: + reasons.append("ML 신호 SELL") + return reasons + + +def _scanner_flags_v2(stock, rule=None) -> dict: + """스캐너 항목의 규칙 출처·차단 사유·buy_allowed·ML 판정. 기존 필드는 건드리지 않는다. + + buy_allowed = 신호 차단 사유 없음 AND ML 게이트 비차단(`_ml_block_reasons_v2`, trade_executor 와 동일 정의). + (MDD·일일한도 등 실행 시점 게이트는 여기서 판정하지 않는다 — 표시용 1차 판정) + """ + reasons = _block_reasons_v2(getattr(stock, "signals", None)) + ml_reasons = _ml_block_reasons_v2(getattr(stock, "ml_signal", None), getattr(stock, "ml_confidence", None)) + reasons = reasons + ml_reasons + matched = rule is not None + return { + "match_source": "rule" if matched else "fallback_high_score", + "match_source_label": "활성 규칙 매칭" if matched else "고점수 분석 종목(규칙 미충족 대체)", + "block_reasons": reasons, + "buy_allowed": not reasons, + "ml_blocks_buy": bool(ml_reasons), + } + + +def _batch_time_iso_v2(stocks) -> str: + """배치 실제 스캔 시각 — KST 타임존 포함 ISO(예: 2026-10-08T09:10:03+09:00). + + DB updated_at/created_at 은 KST naive(core.timeutil). 시각이 없으면 분석일 00:00 KST. + (이전: 날짜만 내려가 브라우저가 UTC 자정 → KST 09:00 으로 표시) + """ + latest = None + for st in stocks: + ts = getattr(st, "updated_at", None) or getattr(st, "created_at", None) + if isinstance(ts, datetime) and (latest is None or ts > latest): + latest = ts + if latest is None: + d = max((st.analysis_date for st in stocks), default=_today_kst()) + if isinstance(d, datetime): + d = d.date() + latest = datetime(d.year, d.month, d.day) + if latest.tzinfo is None: + latest = _KST.localize(latest) + else: + latest = latest.astimezone(_KST) + return latest.isoformat() + + +async def _overlay_realtime_v2(result: dict) -> dict: + """Redis 실시간 틱(tick:{code}, TTL 10s)이 있으면 현재가와 등락률을 **같은 소스**로 덮어쓴다. + + 배치 값(change_rate)은 배치 시점 기준이라 실시간 가격과 섞이면 서로 어긋난다. + 원본 result 는 변경하지 않는다(자동매매 스냅샷과 공유될 수 있음) — 항목 dict 를 복사한다. + 틱이 없는 종목은 배치 값 유지 + price_source="batch". + """ + from app.core.redis import get_json + + lists = [result.get("recommendations") or [], result.get("analyzed_stock_list") or []] + for g in result.get("recommendations_by_rule") or []: + lists.append(g.get("stocks") or []) + codes = sorted({it["stock_code"] for lst in lists for it in lst if it.get("stock_code")})[:400] + if not codes: + return result + ticks = await asyncio.gather(*[get_json(f"tick:{c}") for c in codes], return_exceptions=True) + tick_by = { + c: t for c, t in zip(codes, ticks) + if isinstance(t, dict) and (t.get("price") or 0) > 0 and t.get("change_rate") is not None + } + + def _apply(item: dict) -> dict: + new = dict(item) + t = tick_by.get(item.get("stock_code")) + if t: + new["batch_current_price"] = item.get("current_price") + new["batch_change_rate"] = item.get("change_rate") + new["current_price"] = int(t["price"]) + new["change_rate"] = round(float(t["change_rate"]), 2) + new["price_source"] = "realtime" + else: + new["price_source"] = "batch" + return new + + out = dict(result) + out["recommendations"] = [_apply(i) for i in lists[0]] + out["analyzed_stock_list"] = [_apply(i) for i in lists[1]] + out["recommendations_by_rule"] = [ + {**g, "stocks": [_apply(i) for i in (g.get("stocks") or [])]} + for g in (result.get("recommendations_by_rule") or []) + ] + out["realtime_overlay_count"] = len(tick_by) + return out + + # 매매 규칙 namedtuple Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active']) @@ -60,7 +283,10 @@ def _stock_analysis_to_dict(stock, rule=None, actions=None) -> dict: "ml_score": stock.ml_score, "ml_confidence": round(float(stock.ml_confidence), 4) if stock.ml_confidence else None, "ml_signal": stock.ml_signal, - "price_date": stock.analysis_date.strftime("%Y%m%d"), + "price_date": _row_price_date_v2(stock)[0], + "price_stale": _row_price_date_v2(stock)[1], + "price_source": "batch", + **_scanner_flags_v2(stock, rule), } if rule is not None: _a = actions or {} @@ -167,6 +393,17 @@ async def get_recommendations( "recommendations": recommendations[:20] } +# 이벤트 루프는 태스크를 약참조로만 보관 — 주문 경로가 중간 GC 되지 않도록 강참조 보관 +_bg_trade_tasks: set = set() + + +def _spawn_bg_trade_v2(coro) -> "asyncio.Task": + task = asyncio.create_task(coro) + _bg_trade_tasks.add(task) + task.add_done_callback(_bg_trade_tasks.discard) + return task + + @router.get("/rule-based") async def get_rule_based_recommendations( realtime: bool = Query(False, description="실시간 분석 여부 (느림, 30초)"), @@ -188,10 +425,50 @@ async def get_rule_based_recommendations( await execute_auto_trade_background(rec_snapshot, bg_db) except Exception as e: logger.error(f"[_bg_trade] 백그라운드 자동매매 실행 중 예외: {e}", exc_info=True) - asyncio.create_task(_bg_trade()) - + _spawn_bg_trade_v2(_bg_trade()) + + # 표시용 실시간 보정(현재가+등락률 동일 소스). 매매 스냅샷(rec_snapshot)은 원본 유지. + if not realtime: + try: + return await _overlay_realtime_v2(result) + except Exception as e: + logger.warning(f"[rule-based] 실시간 오버레이 실패 — 배치 값 사용: {e}") return result +def _merge_json_mean_reversion_rule_v2(rules: list) -> list: + """DB 활성 규칙에 MEAN_REVERSION 이 없으면 trading_rules_config.json 의 MR 규칙을 병합한다. + + 운영 DB 에는 BUY/SELL/SELL 3개뿐이라 `is_mean_reversion_candidate`(own_trend·ADX·%B 게이트)가 + 한 번도 호출되지 않았다. 진입 집합이 크게 바뀌므로 플래그 + `MEAN_REVERSION_RULE_MERGE_ENABLED`(기본 False, §8.4 백테스트 후 켤 것)로 제어한다. + 플래그/파일 처리는 병합 자체에만 영향 — 읽기 실패는 warning 후 DB 규칙 그대로 반환. + """ + if not getattr(settings, "MEAN_REVERSION_RULE_MERGE_ENABLED", False): + return rules + if any(str(getattr(r, "rule_type", "") or "").upper() == "MEAN_REVERSION" for r in rules): + return rules + try: + import json as _json + from pathlib import Path as _Path + config_path = _Path(__file__).parent.parent.parent.parent / "trading_rules_config.json" + with open(config_path, 'r', encoding='utf-8') as f: + config = _json.load(f) + mr = [r for r in config.get("rules", []) + if r.get("is_active") and str(r.get("rule_type", "")).upper() == "MEAN_REVERSION"] + except Exception as e: + logger.warning(f"JSON MEAN_REVERSION 규칙 병합 실패 — DB 규칙만 사용: {e}") + return rules + if not mr: + return rules + r = mr[0] + next_id = max([int(x.id) for x in rules if x.id is not None] + [0]) + 1 + return rules + [Rule( + id=next_id, name=r["name"], description=r.get("description", ""), + rule_type="MEAN_REVERSION", priority=r.get("priority", 1), + conditions=r.get("conditions", {}), actions=r.get("actions", {}), is_active=True, + )] + + async def _load_active_rules_from_db(db: AsyncSession) -> list: from app.models.models import TradingRule as TradingRuleModel try: @@ -202,12 +479,13 @@ async def _load_active_rules_from_db(db: AsyncSession) -> list: ) db_rules = result.scalars().all() if db_rules: - return [ + rules = [ Rule(id=r.id, name=r.name, description=r.description or "", rule_type=r.rule_type, priority=r.priority, conditions=r.conditions, actions=r.actions, is_active=True) for r in db_rules ] + return _merge_json_mean_reversion_rule_v2(rules) except Exception as e: logger.warning(f"trading_rules DB 조회 실패: {e}") @@ -451,6 +729,16 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession): continue already_added.add(stock.stock_code) stock_dict = _stock_analysis_to_dict(stock) + # 규칙 미충족 대체 목록은 매수 후보가 아니다: MR 게이트(own_trend·ADX·%B·RSI)를 통과한 + # 종목만 매수 가능, 나머지는 청산 판정 전용(_exit_only — trade_executor 가 should_buy 에서 SKIP). + # 게이트 판정 예외는 fail-closed(청산 전용). + try: + _mr_ok = bool(is_mean_reversion_candidate(stock, {}, current_market_regime)) + except Exception as e: + logger.warning(f"[rule-based] 대체 목록 MR 게이트 판정 실패 — 청산 전용 처리 {stock.stock_code}: {e}") + _mr_ok = False + stock_dict["_exit_only"] = not _mr_ok + stock_dict["buy_allowed"] = bool(stock_dict.get("buy_allowed")) and _mr_ok fallback_stocks.append(stock_dict) rule_matches.append(stock_dict) @@ -479,13 +767,20 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession): "stochastic_k": round(float(s.stochastic_k), 2) if s.stochastic_k else None, "signals": s.signals or [], "score": s.score or 0, + "ml_signal": s.ml_signal, + "ml_confidence": round(float(s.ml_confidence), 4) if s.ml_confidence else None, + "price_source": "batch", + "block_reasons": _block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence), + "buy_allowed": not (_block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence)), } for s in all_analyzed_stocks ], "recommendations": rule_matches, "recommendations_by_rule": recommendations_by_rule, "rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules], - "batch_time": (lambda d: d.date() if isinstance(d, datetime) else d)( + # batch_time: 실제 스캔 시각(KST 오프셋 포함 ISO). 날짜만 필요하면 batch_date. + "batch_time": _batch_time_iso_v2(all_analyzed_stocks), + "batch_date": (lambda d: d.date() if isinstance(d, datetime) else d)( max((s.analysis_date for s in all_analyzed_stocks), default=_today_kst()) ).isoformat(), } @@ -627,8 +922,7 @@ async def get_rule_based_recommendations_realtime(): elif rule.rule_type in ("TREND", "BREAKOUT", "MOMENTUM"): matches = False else: - rsi_threshold = conditions.get("rsi_threshold", 40) - matches = (stock.rsi is not None and stock.rsi <= rsi_threshold) + matches = _generic_rule_matches_v2(rule, stock, conditions) if matches: rule_matched_stocks.append(stock) @@ -673,8 +967,11 @@ async def get_rule_based_recommendations_realtime(): # db_worker 큐 경유 — 실시간 분석 경로에서 AsyncSessionLocal 직접 사용 금지 (CLAUDE.md 3.2) from app.services.analysis_db_service import queue_bulk_upsert_analysis as _queue_bulk _today = _today_kst() - _items = [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks] - await _queue_bulk(_items, _today) + _items = _persistable_items_v2( + [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks], _today + ) + if _items: + await _queue_bulk(_items, _today) return { "total": len(rule_matches), diff --git a/backend/app/api/endpoints/risk_management.py b/backend/app/api/endpoints/risk_management.py index c40c6da..fa6380c 100644 --- a/backend/app/api/endpoints/risk_management.py +++ b/backend/app/api/endpoints/risk_management.py @@ -22,6 +22,14 @@ from app.core.database import get_db _KST = timezone(timedelta(hours=9)) +def _fill_or_created_at_v2(): + """체결 시각 표현 — executed_at 이 NULL 인 DT·스윙 청산·MDD 행은 created_at 으로 대체. + + trade_executor._fill_or_created_at_v2 와 동일 정의(무거운 import 회피를 위해 로컬 정의). + """ + return func.coalesce(TradeHistory.executed_at, TradeHistory.created_at) + + def _today_kst() -> date: """KST 기준 오늘 날짜 반환 (UTC 기준 date.today() 대신 항상 이 함수 사용)""" return datetime.now(_KST).date() @@ -38,13 +46,125 @@ MAX_POSITION_SIZE_RATIO = settings.MAX_POSITION_SIZE # config.py: 0.04 (4%) — # 데이트레이딩은 설계상 종목당 6%(DAY_TRADE_MAX_CAPITAL_RATIO, 5종목×6%=30%)이므로 # 스윙 상한(4%)으로 검사하면 정상 DT 주문이 '위반'으로 오판정된다 → 전략별로 분기한다. DT_POSITION_SIZE_RATIO = settings.DAY_TRADE_MAX_CAPITAL_RATIO -MAX_DAILY_LOSS_RATIO = 0.03 # 일일 3% 손실 시 중단 -MAX_CONSECUTIVE_LOSSES = 3 # 3연속 손실 시 중단 +# 한도는 settings 우선(config.py 에 키가 생기면 자동 반영), 없으면 기존 값 유지 — 값 변경 없음. +MAX_DAILY_LOSS_RATIO = float(getattr(settings, "RISK_MAX_DAILY_LOSS_RATIO", 0.03)) # 일일 3% 손실 시 중단 +MAX_CONSECUTIVE_LOSSES = int(getattr(settings, "RISK_MAX_CONSECUTIVE_LOSSES", 3)) # 3연속 손실 시 중단 +_DT_STRATEGIES = ("daytrading", "day_trade", "dt") COMMISSION_RATE = 0.00015 # 수수료 0.015% TRADE_TAX_RATE = 0.0023 # 거래세 0.23% (매도 시만) TOTAL_COST_RATE = COMMISSION_RATE * 2 + TRADE_TAX_RATE # 왕복 약 0.26% +def _resolve_account_balance(caller_balance: float): + """호출자 잔고와 서버 잔고(mdd_manager 최근 갱신값, 없으면 peak 미사용)를 대조한다. + + 서버값이 있으면 min(호출자, 서버) — 큰 값 주입으로 한도를 희석하는 우회를 막는 보수적 선택. + 서버값이 없으면 호출자 값을 쓰되 note 로 미검증을 표시한다. + """ + from app.services.mdd_manager import mdd_manager + try: + server = float(getattr(mdd_manager, "_last_balance", 0.0) or 0.0) + except (TypeError, ValueError): + server = 0.0 + caller = float(caller_balance) + if server > 0: + if caller > server: + logger.warning( + f"[check-risk-limits] 호출자 잔고 {caller:,.0f} > 서버 잔고 {server:,.0f} — 서버값 사용" + ) + return server, "server_balance_used" + return caller, "ok" + return caller, "server_balance_unverified" + + +async def _server_side_block_reason_v2(strategy: str, account_balance: float) -> Optional[str]: + """TradingSession 행(기록 주체 없음)과 무관하게 서버 인메모리 상태로 신규 주문 차단 사유를 낸다. + + - mdd_manager.can_open_new_position(): MDD -10% 정지 / 갱신 블라인드 fail-safe + - DT 전략: day_trading_state.daily_pnl 이 일일 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT) 이하 + 조회 예외는 fail-closed(차단)로 처리한다. + """ + from app.services.mdd_manager import mdd_manager + try: + if not await mdd_manager.can_open_new_position(): + return "MDD 정지 또는 MDD 갱신 불가(fail-safe) — 신규 진입 차단" + if (strategy or "").strip().lower() in ("daytrading", "day_trade", "dt"): + from app.services.daytrading.position_manager import day_trading_state + from app.services.day_trading_strategy import is_daily_loss_exceeded + pnl = float(day_trading_state.daily_pnl or 0.0) + assets = float(day_trading_state.last_total_assets or 0.0) or float(account_balance) + if is_daily_loss_exceeded(assets, pnl): + return (f"DT 일일 손실한도 도달 (pnl {pnl:,.0f}, " + f"한도 {float(settings.DAY_TRADE_DAILY_LOSS_LIMIT) * 100:+.1f}%)") + except Exception as e: + logger.warning(f"[check-risk-limits] 서버 상태 조회 실패 — fail-closed: {e}") + return "서버 리스크 상태 조회 실패 — 신규 진입 차단(fail-closed)" + return None + + +_STREAK_EXCLUDED_STRATEGIES = ("manual", "mdd_reduction", "external") + + +async def _swing_realized_today_v2(db, stock_code: Optional[str] = None) -> tuple: + """TradingSession 기록 주체가 없으므로 TradeHistory 에서 스윙 당일 실현손익·연속 손실을 직접 계산한다. + + 반환: (당일 순손익 합, 최근 연속 손실 횟수). SELECT 전용. 조회 실패 시 예외를 그대로 올린다 + (호출부가 fail-closed 처리). DT 행은 제외한다. + 연패는 §5.1 의미(동일 종목)에 맞춰 stock_code 가 주어질 때만 해당 종목으로 집계하고, + manual/mdd_reduction/external 청산은 제외한다. stock_code 가 없으면 0 + (종목 무관 전역 연패는 §5.1 정의가 아니므로 loss_tracker 에 맡긴다). + """ + start = now_kst().replace(hour=0, minute=0, second=0, microsecond=0) + res = await db.execute( + select(TradeHistory.net_profit_loss, TradeHistory.stock_code, TradeHistory.strategy_type) + .where(TradeHistory.order_type == "SELL") + .where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"])) + .where(TradeHistory.created_at >= start) + .where(TradeHistory.net_profit_loss.isnot(None)) + .where(TradeHistory.strategy_type.notin_(["daytrading", "day_trade"])) + .order_by(desc(TradeHistory.created_at)) + ) + rows = res.all() + total = sum(float(r[0]) for r in rows) + streak = 0 + if stock_code: + for pl, code, st in rows: + if code != stock_code or st in _STREAK_EXCLUDED_STRATEGIES: + continue + if float(pl) < 0: + streak += 1 + else: + break + return total, streak + + +def _mdd_state_v2() -> dict: + """실제 MDD 게이트(mdd_manager) 상태 스냅샷. 읽기 전용, DB 접근 없음. + + drawdown_pct 는 (peak - 최근 관측 잔고) / peak * 100 (양수 = 하락폭). + peak 또는 최근 잔고를 모르면 None (0% '안전' 으로 오표시하지 않는다). + """ + from app.services.mdd_manager import mdd_manager + peak = float(getattr(mdd_manager, "peak_balance", 0.0) or 0.0) + last = float(getattr(mdd_manager, "_last_balance", 0.0) or 0.0) + dd_pct = round((peak - last) / peak * 100, 2) if peak > 0 and last > 0 else None + return { + "peak_balance": peak if peak > 0 else None, + "last_balance": last if last > 0 else None, + "drawdown_pct": dd_pct, + "is_paused": bool(mdd_manager.is_paused), + "is_short_term_strategy_paused": bool(getattr(mdd_manager, "is_short_term_strategy_paused", False)), + "reduction_applied": mdd_manager.reduction_applied, + "update_health": mdd_manager.get_update_health(), + } + + +@router.get("/mdd-status") +async def get_mdd_status_v2(): + """실제 MDD 게이트(-10%/-20%) 상태. /risk-metrics 의 손익곡선과 정의가 다르다.""" + return _mdd_state_v2() + + @router.get("/risk-metrics") async def get_risk_metrics( days: int = Query(30, description="분석 기간 (일)"), @@ -53,7 +173,7 @@ async def get_risk_metrics( """ 📊 리스크 관리 메트릭 조회 - - Maximum Drawdown (MDD): 최대 손실 깊이 + - 실현손익 누적곡선의 최대 낙폭(원, 참고용). 실제 MDD 는 mdd_status / /mdd-status - 누적 손익 곡선 - 현재 계좌 상태 """ @@ -66,14 +186,15 @@ async def get_risk_metrics( .where(TradeHistory.created_at >= since_date) .where(TradeHistory.order_type == "SELL") .where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"])) - .order_by(TradeHistory.executed_at) + .order_by(_fill_or_created_at_v2(), TradeHistory.id) ) trades = result.scalars().all() if not trades: return { "max_drawdown": 0, - "max_drawdown_pct": 0, + "max_drawdown_pct": None, + "mdd_status": _mdd_state_v2(), "current_equity": 0, "peak_equity": 0, "trades_count": 0, @@ -112,24 +233,16 @@ async def get_risk_metrics( if drawdown > max_drawdown: max_drawdown = drawdown - max_drawdown_pct = (max_drawdown / peak * 100) if peak > 0 else 0 - - # MDD 위험 레벨 - risk_level = "안전" - if max_drawdown_pct > 30: - risk_level = "위험 (시스템 중지 권장)" - elif max_drawdown_pct > 20: - risk_level = "경고 (포지션 축소 권장)" - elif max_drawdown_pct > 10: - risk_level = "주의" - + # 참고용 실현손익 곡선 — 낙폭 % 는 peak 가 손익 누적값이라 의미가 없어 판정하지 않는다. return { "max_drawdown": round(float(max_drawdown), 2), - "max_drawdown_pct": round(max_drawdown_pct, 2), + "max_drawdown_pct": None, "current_equity": round(cumulative_pl, 2), "peak_equity": round(peak, 2), "trades_count": len(trades), - "risk_level": risk_level, + "risk_level": None, + "note": "실현손익 누적곡선(참고용). 리스크 수준 판단은 mdd_status 를 사용", + "mdd_status": _mdd_state_v2(), "equity_curve": equity_curve[-50:], # 최근 50개만 "drawdown_curve": drawdown_curve[-50:] } @@ -161,6 +274,7 @@ async def get_sharpe_ratio( .where(TradeHistory.order_type == "SELL") .where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"])) .where(TradeHistory.profit_loss_rate.isnot(None)) + .order_by(_fill_or_created_at_v2(), TradeHistory.id) ) trades = result.scalars().all() @@ -240,28 +354,33 @@ async def get_today_session(db: AsyncSession = Depends(get_db)): ) session = result.scalar_one_or_none() + _mdd = _mdd_state_v2() if not session: - # 오늘 세션이 없으면 생성 - session = TradingSession( - session_date=today, - consecutive_losses=0, - consecutive_wins=0, - daily_profit_loss=0, - daily_trades_count=0, - is_paused=False - ) - db.add(session) - await db.commit() - await db.refresh(session) - + # [하드닝 R4] GET 은 DB 를 쓰지 않는다(§3.2: 쓰기는 db_task_queue 경유, 무인증 GET 부하 방지). + # 세션 행을 쓰는 엔드포인트는 없다(R1 에서 /update-session 제거). + return { + "session_date": today.isoformat(), + "consecutive_losses": 0, + "consecutive_wins": 0, + "daily_profit_loss": 0, + "daily_trades_count": 0, + "is_paused": _mdd["is_paused"], + "pause_reason": "MDD 정지" if _mdd["is_paused"] else None, + "mdd_status": _mdd, + "starting_balance": None, + "ending_balance": None, + "peak_balance": None, + } + return { "session_date": session.session_date.isoformat(), "consecutive_losses": session.consecutive_losses, "consecutive_wins": session.consecutive_wins, "daily_profit_loss": float(session.daily_profit_loss) if session.daily_profit_loss else 0, "daily_trades_count": session.daily_trades_count, - "is_paused": session.is_paused, - "pause_reason": session.pause_reason, + "is_paused": bool(session.is_paused) or _mdd["is_paused"], + "pause_reason": session.pause_reason or ("MDD 정지" if _mdd["is_paused"] else None), + "mdd_status": _mdd, "starting_balance": float(session.starting_balance) if session.starting_balance else None, "ending_balance": float(session.ending_balance) if session.ending_balance else None, "peak_balance": float(session.peak_balance) if session.peak_balance else None @@ -277,11 +396,15 @@ async def check_risk_limits( order_value: float = Query(..., description="주문 금액"), account_balance: float = Query(..., description="계좌 잔고"), strategy: str = Query("swing", description="전략 구분: swing | daytrading (종목당 한도 분기)"), + stock_code: Optional[str] = Query(None, description="종목 단위 연패 집계용(없으면 연패 가드 생략)"), db: AsyncSession = Depends(get_db) ): """ ⚠️ 주문 전 리스크 제한 확인 + [미사용 표기] 2026-10 기준 코드베이스(py/ts/mcp-server)에 호출자가 없다. 실제 진입 가드는 + trade_executor/entry_service/buy_guards 에 있으며 이 엔드포인트 보강은 매매 안전에 기여하지 않는다. + - 포지션 사이즈 제한 (전략별: 스윙 MAX_POSITION_SIZE / DT DAY_TRADE_MAX_CAPITAL_RATIO) - 일일 손실 한도 - 연속 손실 제한 @@ -289,6 +412,31 @@ async def check_risk_limits( [2026-09-28] `strategy` 는 기본값이 있는 추가 파라미터다(기존 호출부 동작 불변). 이전에는 DT 주문(설계상 6%)을 스윙 상한 4%로 검사해 정상 주문을 위반으로 냈다. """ + # [하드닝 R4] 호출자 제공 잔고 신뢰 제거 — 서버 잔고(mdd_manager)가 있으면 더 작은 쪽 사용. + # (가드는 try 밖) 큰 값을 넘겨 손실비율·포지션비율을 희석하는 우회를 막는다. + account_balance, _bal_note = _resolve_account_balance(account_balance) + # [하드닝 R1] 서버측 실상태 가드(try 밖): MDD 정지/블라인드, DT 일일 손실한도. + _block = await _server_side_block_reason_v2(strategy, account_balance) + if _block: + return {"allowed": False, "reason": _block, "risk_level": "BLOCKED"} + # [하드닝 R3] 스윙 일일손실·연패는 TradingSession(기록 주체 없음) 대신 TradeHistory 실계산으로 가드(try 밖, fail-closed). + _sw_pnl, _sw_streak = 0.0, 0 + if (strategy or "swing").strip().lower() not in _DT_STRATEGIES: + try: + _sw_pnl, _sw_streak = await _swing_realized_today_v2(db, stock_code=stock_code) + except Exception as e: + logger.warning(f"[check-risk-limits] 스윙 당일 손익 조회 실패 — fail-closed: {e}") + return {"allowed": False, "reason": "당일 손익 조회 실패 — 신규 진입 차단(fail-closed)", + "risk_level": "BLOCKED"} + if _sw_pnl < 0 and account_balance > 0 and abs(_sw_pnl) / account_balance > MAX_DAILY_LOSS_RATIO: + _r = abs(_sw_pnl) / account_balance + return {"allowed": False, + "reason": f"일일 손실 한도 도달 ({_r*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)", + "risk_level": "CRITICAL", "daily_loss": _sw_pnl, + "daily_loss_ratio": round(_r * 100, 2)} + if _sw_streak >= MAX_CONSECUTIVE_LOSSES: + return {"allowed": False, "reason": f"연속 {_sw_streak}회 손실 발생", + "risk_level": "CRITICAL", "consecutive_losses": _sw_streak} try: # 1. 오늘 세션 확인 today = _today_kst() @@ -299,18 +447,16 @@ async def check_risk_limits( session = result.scalar_one_or_none() if not session: + # [하드닝 R4] 비영속 기본값 — 호출자 제공 잔고를 하루 기준으로 영속하지 않고, + # 직접 INSERT/commit(동시 호출 시 session_date 중복 경합)도 하지 않는다. session = TradingSession( session_date=today, - starting_balance=Decimal(str(account_balance)), consecutive_losses=0, consecutive_wins=0, daily_profit_loss=0, daily_trades_count=0, is_paused=False ) - db.add(session) - await db.commit() - await db.refresh(session) # 2. 거래 중지 상태 확인 if session.is_paused: @@ -324,13 +470,12 @@ async def check_risk_limits( daily_loss_ratio = abs(float(session.daily_profit_loss)) / account_balance if account_balance > 0 else 0 if session.daily_profit_loss < 0 and daily_loss_ratio > MAX_DAILY_LOSS_RATIO: # 자동 중지 - session.is_paused = True - session.pause_reason = f"일일 손실 한도 도달 ({daily_loss_ratio*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)" - await db.commit() - + # is_paused 영속은 하지 않는다(직접 commit 제거). 손익·연패는 세션에 남아 있어 + # 매 호출마다 같은 판정이 재현된다. + _reason = f"일일 손실 한도 도달 ({daily_loss_ratio*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)" return { "allowed": False, - "reason": session.pause_reason, + "reason": _reason, "risk_level": "CRITICAL", "daily_loss": float(session.daily_profit_loss), "daily_loss_ratio": round(daily_loss_ratio * 100, 2) @@ -338,13 +483,9 @@ async def check_risk_limits( # 4. 연속 손실 제한 확인 if session.consecutive_losses >= MAX_CONSECUTIVE_LOSSES: - session.is_paused = True - session.pause_reason = f"연속 {session.consecutive_losses}회 손실 발생" - await db.commit() - return { "allowed": False, - "reason": session.pause_reason, + "reason": f"연속 {session.consecutive_losses}회 손실 발생", "risk_level": "CRITICAL", "consecutive_losses": session.consecutive_losses } @@ -380,7 +521,8 @@ async def check_risk_limits( "warnings": warnings, "position_ratio": round(position_ratio * 100, 2), "daily_loss_ratio": round(daily_loss_ratio * 100, 2), - "consecutive_losses": session.consecutive_losses + "consecutive_losses": session.consecutive_losses, + "balance_note": _bal_note, } except Exception as e: @@ -388,65 +530,9 @@ async def check_risk_limits( raise HTTPException(status_code=500, detail=str(e)) -@router.post("/update-session", dependencies=[Depends(require_api_key)]) -async def update_trading_session( - trade_result: str = Query(..., description="win 또는 loss"), - profit_loss: float = Query(..., description="거래 손익"), - db: AsyncSession = Depends(get_db) -): - """ - 🔄 거래 결과에 따라 세션 업데이트 - - - 연속 손실/승리 카운터 - - 일일 손익 누적 - """ - try: - today = _today_kst() - result = await db.execute( - select(TradingSession) - .where(TradingSession.session_date == today) - ) - session = result.scalar_one_or_none() - - if not session: - session = TradingSession( - session_date=today, - consecutive_losses=0, - consecutive_wins=0, - daily_profit_loss=0, - daily_trades_count=0, - is_paused=False - ) - db.add(session) - - # 손익 누적 - current_pl = float(session.daily_profit_loss) if session.daily_profit_loss else 0 - session.daily_profit_loss = Decimal(str(current_pl + profit_loss)) - session.daily_trades_count += 1 - - # 연속 손실/승리 업데이트 - if trade_result == "loss": - session.consecutive_losses += 1 - session.consecutive_wins = 0 - elif trade_result == "win": - session.consecutive_wins += 1 - session.consecutive_losses = 0 - - await db.commit() - await db.refresh(session) - - return { - "session_date": session.session_date.isoformat(), - "consecutive_losses": session.consecutive_losses, - "consecutive_wins": session.consecutive_wins, - "daily_profit_loss": float(session.daily_profit_loss), - "daily_trades_count": session.daily_trades_count, - "is_paused": session.is_paused - } - - except Exception as e: - logger.error(f"Error updating session: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) +# [하드닝 R1] POST /update-session 제거: 직접 commit(§3.2 위반)·입력 무검증이었고 호출부가 0건이라 +# 세션 행이 생기지 않아 check-risk-limits 의 일일손실/연패 가드가 항상 통과했다. +# 서버측 실상태(mdd_manager, DT daily_pnl)를 check-risk-limits 가 직접 읽는다. @router.get("/win-rate") @@ -533,7 +619,7 @@ async def reset_mdd(): await mdd_manager.reset() logger.info("✅ MDD 수동 초기화 완료") return { - "message": "MDD 초기화 완료 — 신규 진입 가능", + "message": "MDD 초기화 완료 — peak 를 현재 잔고로 재설정(이후 다시 -10% 하락 시 축소 재발동), 신규 진입 가능", "before": before, "after": { "is_paused": mdd_manager.is_paused, diff --git a/backend/app/api/endpoints/strategy_config.py b/backend/app/api/endpoints/strategy_config.py index 3762b91..1dd231a 100644 --- a/backend/app/api/endpoints/strategy_config.py +++ b/backend/app/api/endpoints/strategy_config.py @@ -18,7 +18,11 @@ from sqlalchemy import select, update from app.core.database import AsyncSessionLocal from app.models.models import StrategyConfig -from app.services.strategy_config_service import strategy_cfg +from app.services.strategy_config_service import ( + get_effective_dt_params_v2, + serialize_config_v2, + strategy_cfg, +) router = APIRouter() logger = logging.getLogger(__name__) @@ -39,6 +43,7 @@ class StrategyConfigResponse(BaseModel): max_value: Optional[str] is_active: bool updated_by: Optional[str] + read_only: bool = False class Config: from_attributes = True @@ -74,7 +79,7 @@ async def list_configs( if active_only: stmt = stmt.where(StrategyConfig.is_active == True) result = await session.execute(stmt) - return result.scalars().all() + return [serialize_config_v2(c) for c in result.scalars().all()] @router.get("/groups", response_model=List[str]) @@ -83,6 +88,12 @@ async def list_groups(): return ["INDICATOR", "RISK", "FILTER", "DAY_TRADE", "MEAN_REVERSION", "GENERAL"] +@router.get("/dt-effective", response_model=Dict[str, Any]) +async def get_dt_effective(): + """DT 엔진이 실제로 쓰는 값(settings 단일 소스). 읽기 전용.""" + return get_effective_dt_params_v2() + + @router.get("/cache-snapshot", response_model=Dict[str, Any]) async def get_cache_snapshot(): """현재 인메모리 캐시 상태 조회 (디버그용).""" @@ -99,7 +110,7 @@ async def get_config(key: str): cfg = result.scalar_one_or_none() if not cfg: raise HTTPException(status_code=404, detail=f"파라미터 '{key}'를 찾을 수 없습니다.") - return cfg + return serialize_config_v2(cfg) @router.put("/{key}", response_model=StrategyConfigResponse) @@ -134,7 +145,10 @@ async def bulk_update(body: BulkUpdateRequest): @router.post("/{key}/reset", response_model=StrategyConfigResponse) async def reset_config(key: str): """단일 파라미터를 기본값으로 리셋.""" - cfg = await strategy_cfg.reset_to_default(key) + try: + cfg = await strategy_cfg.reset_to_default(key) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) if not cfg: raise HTTPException(status_code=404, detail=f"파라미터 '{key}'를 찾을 수 없습니다.") return cfg diff --git a/backend/app/api/endpoints/swing_portfolio.py b/backend/app/api/endpoints/swing_portfolio.py index 04b4fa2..8784b89 100644 --- a/backend/app/api/endpoints/swing_portfolio.py +++ b/backend/app/api/endpoints/swing_portfolio.py @@ -40,7 +40,10 @@ class SwingSellRequest(BaseModel): @router.get("/portfolio") async def get_swing_portfolio(db: AsyncSession = Depends(get_db)): """스윙 전용 포지션 목록 (strategy_type='swing').""" - holdings = await get_swing_holdings(db) + holdings = list(await get_swing_holdings(db)) + # [2026-10-08] 전략별 분해(swing_* / dt_*) — 기존 필드는 계좌 합산·혼합 평단 그대로 + from app.api.endpoints.portfolio import _annotate_strategy_breakdown_v2, _BREAKDOWN_FIELDS + await _annotate_strategy_breakdown_v2(db, holdings) return { "count": len(holdings), "positions": [ @@ -53,6 +56,7 @@ async def get_swing_portfolio(db: AsyncSession = Depends(get_db)): "profit_loss": float(h.profit_loss or 0), "profit_loss_rate": float(h.profit_loss_rate or 0), "strategy_type": h.strategy_type, + **{k: getattr(h, k, None) for k in _BREAKDOWN_FIELDS}, } for h in holdings ], @@ -62,9 +66,21 @@ async def get_swing_portfolio(db: AsyncSession = Depends(get_db)): @router.get("/portfolio/summary") async def get_swing_summary(db: AsyncSession = Depends(get_db)): """스윙 포지션 수익률 요약.""" - holdings = await get_swing_holdings(db) - total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings) - total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings) + holdings = list(await get_swing_holdings(db)) + # [2026-10-08] 스윙 보유분(swing_quantity)·스윙 진입가(swing_avg_price) 기준 — 혼합 평단 회피 + from app.api.endpoints.portfolio import _annotate_strategy_breakdown_v2 + await _annotate_strategy_breakdown_v2(db, holdings) + + def _sq(h): + v = getattr(h, "swing_quantity", None) + return int(h.quantity or 0) if v is None else int(v) + + def _sp(h): + v = getattr(h, "swing_avg_price", None) + return float(h.average_price or 0) if v is None else float(v) + + total_investment = sum(_sp(h) * _sq(h) for h in holdings) + total_value = sum(float(h.current_price or 0) * _sq(h) for h in holdings) total_pl = total_value - total_investment total_pl_rate = (total_pl / total_investment * 100) if total_investment > 0 else 0.0 @@ -80,11 +96,14 @@ async def get_swing_summary(db: AsyncSession = Depends(get_db)): @router.get("/exit-candidates") async def get_exit_candidates(db: AsyncSession = Depends(get_db)): """청산 조건에 근접한 스윙 후보 종목 조회 (읽기 전용, 실제 청산 없음).""" - holdings = await get_swing_holdings(db) + holdings = list(await get_swing_holdings(db)) + from app.api.endpoints.portfolio import _annotate_strategy_breakdown_v2 + await _annotate_strategy_breakdown_v2(db, holdings) candidates = [] for h in holdings: - avg_price = float(h.average_price or 0) + # [2026-10-08] 스윙 진입가 기준(혼합 평단이면 손익률이 왜곡된다) + avg_price = float(getattr(h, "swing_avg_price", None) or h.average_price or 0) current_price = float(h.current_price or 0) if avg_price <= 0 or current_price <= 0: continue @@ -178,38 +197,73 @@ async def manual_swing_sell( order_id = (order_resp.get("output") or {}).get("ODNO") from app.services.db_worker import enqueue_trade_history - avg_price = float(holding.average_price or 0) + # [2026-10-08] 스윙 진입가(혼합 평단 회피) — 스윙 ActiveTrade 우선 + from app.services.swing.position_manager import ( + get_swing_active_trade, resolve_swing_entry_price_v2, + ) + # 주문은 이미 전송됐다 — 진입가 조회 실패가 TradeHistory 기록을 막으면 원장이 유실되므로 + # 실패 시 Portfolio 평단으로 폴백(기록 손익은 추정치, 체결 확인이 보정한다). + try: + _at = await get_swing_active_trade(db, req.stock_code) + except Exception as _at_err: + logger.warning( + f"[SwingManualSell] {req.stock_code} 스윙 진입가 조회 실패 — Portfolio 평단으로 기록: " + f"{type(_at_err).__name__}: {_at_err}" + ) + _at = None + avg_price, _ = resolve_swing_entry_price_v2( + getattr(_at, "entry_price", None) if _at is not None else None, + float(holding.average_price or 0), req.stock_code, + ) pl = (price - avg_price) * quantity pl_rate = (price - avg_price) / avg_price * 100 if avg_price > 0 else 0.0 + # [하드닝 R2 2026-10-09] 순손익(비용 차감, §5.1) — 자동 경로(swing/exit_service)와 동일 정의. + # 시장 구분 정보가 Portfolio 에 없어 KOSPI 세율 적용(추정치, 체결 확인이 보정). + # TradeHistory enqueue 전에 이전 레그 누적을 조회해야 이번 레그가 이중 합산되지 않는다. + from app.services.swing.exit_service import ( + _swing_net_pnl_v2, _position_is_loss_v2, _prior_position_realized_net_v2, + ) + alloc = allocate_sell_v2(quantity, row_qty, dt_qty, swing_only=True) + _pnl = _swing_net_pnl_v2(avg_price, price, quantity, "KOSPI") + net_pl = float(_pnl["net_profit_loss"]) + # fill_sweep.derive_sell_entry_price_v2 가 entry = p − (net + commission)/q 로 역산하므로 + # 비용 차감 net 을 기록할 땐 commission 도 함께 기록해야 한다(자동 경로와 동일 규약). + total_cost = float(_pnl["total_cost"]) + prior_net = None + if alloc.swing_left <= 0 and _at is not None: + prior_net = await _prior_position_realized_net_v2(db, req.stock_code, _at, "KOSPI") + await enqueue_trade_history({ "stock_code": req.stock_code, "stock_name": holding.stock_name, "order_type": "SELL", "order_price": price, "order_quantity": quantity, - "executed_price": price, + # [2026-10-08] 시장가 — 체결가는 체결 확인(swing/fill_sweep)이 채운다. 금액·손익은 추정치. + "executed_price": None, "executed_quantity": quantity, "total_amount": price * quantity, "status": "COMPLETED", "order_id": order_id, "exit_reason": "수동 청산", - "net_profit_loss": pl, + "net_profit_loss": net_pl, + "commission": total_cost, "profit_loss_rate": pl_rate, "strategy_type": "swing", }) # ActiveTrade(비-DT 행만) + Portfolio(행 − 매도분, 큐 경유·캐시 반영) - alloc = allocate_sell_v2(quantity, row_qty, dt_qty, swing_only=True) await sync_active_trade_after_manual_sell_v2(req.stock_code, alloc, price=float(price)) from app.services.portfolio_cache_bridge import reflect_sell await reflect_sell(req.stock_code, quantity, float(price), remaining_qty=row_qty - quantity) # LossTracker — 스윙분 전량 청산 시에만(분할 매도 이중 카운트 방지, swing/exit_service 와 동일) if alloc.swing_left <= 0: + _is_loss = _position_is_loss_v2(prior_net, net_pl) # try 밖: 판정 예외가 기록을 삼키지 않게 try: from app.services.loss_tracker import loss_tracker - await loss_tracker.record_trade_result_safe(stock_code=req.stock_code, is_loss=pl < 0) + await loss_tracker.record_trade_result_safe(stock_code=req.stock_code, is_loss=_is_loss) await loss_tracker.save_to_db(stock_code=req.stock_code) except Exception as e: logger.warning(f"[SwingAPI] LossTracker 기록 실패 {req.stock_code}: {e}") diff --git a/backend/app/api/endpoints/trades.py b/backend/app/api/endpoints/trades.py index 7958a5d..e20a8bf 100644 --- a/backend/app/api/endpoints/trades.py +++ b/backend/app/api/endpoints/trades.py @@ -172,10 +172,16 @@ async def get_trade_stats( executed_trades = len([t for t in trades if t.status in ("EXECUTED", "COMPLETED")]) failed_trades = len([t for t in trades if t.status == "FAILED"]) - # 총 거래 금액 계산 + # 총 거래 금액 계산 — [2026-10-08] total_amount(체결 확인 시 체결가×체결수량으로 갱신) 우선. + # executed_price 는 체결 확인 전 NULL 이므로 곱셈만 쓰면 미확인 건이 0 으로 빠진다. + def _amount(t) -> float: + if t.total_amount is not None: + return float(t.total_amount) + price = t.executed_price if t.executed_price is not None else t.order_price + return float(price or 0) * (t.executed_quantity or 0) + total_amount = sum( - float(t.executed_price or 0) * (t.executed_quantity or 0) - for t in trades if t.status in ("EXECUTED", "COMPLETED") + _amount(t) for t in trades if t.status in ("EXECUTED", "COMPLETED") ) return { @@ -192,7 +198,11 @@ async def get_trade_stats( @router.delete("/pending") async def delete_pending_trades(db: AsyncSession = Depends(get_db)): - """PENDING과 FAILED 상태의 매수 시도 내역 정리 — **전일 이전 행만**. + """FAILED 상태의 주문 시도 내역 정리 — **전일 이전 행만, PENDING 은 보존**. + + [R7] PENDING(지정가 미체결·결과 불명 BUY)은 원장 BUY 합계와 강등 방어의 근거라 지우지 않는다. + 쓰기는 FAILED 전용 정리(기존 동작 유지). db_worker 에 delete task 가 없어 직접 삭제를 유지한다 + (관리자 전용 등록은 security.ADMIN_ONLY_ROUTES 소관 — 별도 보고). [L 2026-10-02] 기존에는 기간 무제한이라 장중 호출 시 당일 PENDING/FAILED 행까지 지웠다. 당일 행은 `already_ordered_today`·일일 스윙 매수 한도(`_count_swing_buys_today_v2` @@ -204,7 +214,9 @@ async def delete_pending_trades(db: AsyncSession = Depends(get_db)): cutoff = get_daily_cutoff_utc() result = await db.execute( select(TradeHistory).where( - TradeHistory.status.in_(['PENDING', 'FAILED']), + # [R7] PENDING 은 보존한다 — 체결 확인 실패 BUY 가 PENDING 으로 남아 + # portfolio 원장 BUY 합계·유입잔고 강등(swing→external) 방어의 근거가 된다(main.py 기동 정리와 동일 정책). + TradeHistory.status == 'FAILED', TradeHistory.created_at < cutoff, ) ) @@ -215,9 +227,9 @@ async def delete_pending_trades(db: AsyncSession = Depends(get_db)): await db.delete(trade) await db.commit() - logger.info(f"✅ 정리 완료: {count}개의 PENDING/FAILED 매수 시도 삭제 (기준 {cutoff} 이전, 당일 행 보존)") + logger.info(f"✅ 정리 완료: {count}개의 FAILED 시도 삭제(PENDING 보존) (기준 {cutoff} 이전, 당일 행 보존)") return { "deleted_count": count, - "message": f"Cleaned up {count} pending/failed trades before {cutoff.isoformat()} (today's rows kept)", + "message": f"Cleaned up {count} failed trades (PENDING kept) before {cutoff.isoformat()} (today's rows kept)", } diff --git a/backend/app/api/endpoints/trades_v2.py b/backend/app/api/endpoints/trades_v2.py index c895e25..3034589 100644 --- a/backend/app/api/endpoints/trades_v2.py +++ b/backend/app/api/endpoints/trades_v2.py @@ -109,10 +109,13 @@ async def delete_pending_trades_v2( 본인 행만)을 적용해 legacy 컨텍스트(관리자/API 키, MT=False)는 기존 동작과 동일하고, 일반 사용자는 본인 이력만 지운다. """ + from app.services.trade_helpers import get_daily_cutoff_utc target_user_id = await _resolve_target_user_id(user, None, db) query = select(TradeHistory).where( - TradeHistory.status.in_(["PENDING", "FAILED"]), + # [R7] PENDING 은 원장/강등 방어 근거라 보존, 당일 행은 한도 근거라 보존(trades.py 와 동일) + TradeHistory.status == "FAILED", + TradeHistory.created_at < get_daily_cutoff_utc(), _user_scope_filter(TradeHistory.user_id, target_user_id), ) result = await db.execute(query) diff --git a/backend/app/core/background_tasks.py b/backend/app/core/background_tasks.py new file mode 100644 index 0000000..87dd24f --- /dev/null +++ b/backend/app/core/background_tasks.py @@ -0,0 +1,27 @@ +"""fire-and-forget 태스크 공용 추적 헬퍼 (하드닝 R3 2026-10-09). + +이벤트 루프는 태스크를 약참조로만 들고 있어 참조를 버리면 실행 중 GC 될 수 있다. +또 종료 drain 은 main._background_tasks 만 기다리므로, 엔드포인트가 만든 태스크도 +같은 집합에 등록해 drain 대상에 포함시킨다(app.main 이 로드된 경우). +""" +import asyncio +import sys + +_tracked: set = set() + + +def _main_task_set(): + mod = sys.modules.get("app.main") + return getattr(mod, "_background_tasks", None) if mod else None + + +def spawn_tracked_v2(coro) -> asyncio.Task: + task = asyncio.create_task(coro) + sets = [_tracked] + main_set = _main_task_set() + if main_set is not None: + sets.append(main_set) + for s in sets: + s.add(task) + task.add_done_callback(s.discard) + return task diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4dbdff3..8883bb0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -185,6 +185,14 @@ class Settings(BaseSettings): # → 매핑 테이블 확충 + 백테스트 검증 후 활성화할 것 (correlation_filter 참고). SECTOR_CAP_UNMAPPED_ENABLED: bool = False + # 상관 필터 (§5.1) — 기본값은 현행 동작 유지. 켜기 전 운영 DB 로 일봉 결손 빈도 확인 필요. + MAX_STOCKS_PER_SECTOR: int = 3 + CORRELATION_THRESHOLD: float = 0.7 + # 보유종목 일봉 부족/공통 거래일 부족으로 비교 제외가 생기면 후보 보류(fail-closed) + CORRELATION_FAIL_CLOSED_ON_MISSING_DATA: bool = False + # 후보 종목 일봉 부족 / 상관 계산 불가 시 보류(fail-closed). 거래량 영향 -> 사용자 결정 전 False + CORRELATION_FAIL_CLOSED_ON_CANDIDATE_DATA: bool = False + # 자본 배분 글로벌 가드 — 전 전략 합산 현금 사용 상한 MAX_TOTAL_CAPITAL_USAGE: float = 0.95 # 전체 자본의 95%까지만 투자 (5% 현금 보유) @@ -387,7 +395,7 @@ class Settings(BaseSettings): DAY_TRADE_RSI_BB_ATR_MULT: float = 1.5 # RSI_BB ATR 손절 배수 DAY_TRADE_TARGET_MULTIPLIER: float = 1.5 # 목표가 = entry + risk × 1.5R (기존 2.0 → 현실적 목표) DAY_TRADE_TIME_STOP_MINUTES: int = 30 # 시간 손절 (30분) - DAY_TRADE_TIME_STOP_MIN_GAIN: float = -0.003 # 시간 손절: -0.3% 미만 시 청산 (기존 +0.5% → 수익 중이면 보유 유지) + DAY_TRADE_TIME_STOP_MIN_GAIN: float = -0.003 # 시간 손절: -0.3% 미만 시 청산. 2026-10-09 -0.7% 변경 원복 — 운영 실측(163건) DT 순손익 +31,233원으로 현행이 이익, 변경 효과는 백테스트 전 미확인 DAY_TRADE_ORB_MARGIN_CHECK: float = 0.03 # ORB 목표가 최소 마진 (3%) DAY_TRADE_TRAILING_DEFAULT: float = 0.02 # 트레일링 기본값 -2% (ATR 없을 때) DAY_TRADE_TRAILING_MIN_HOLD_SEC: int = 300 # 트레일링 활성화 최소 보유 시간 (초, 5분) @@ -667,6 +675,15 @@ class Settings(BaseSettings): DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC: float = 60.0 # 업무 거부가 MAX_RETRIES 에 도달한 뒤에도 당일 포기하지 않고 이 간격으로 계속 재시도. DAY_TRADE_STALE_SELL_EXHAUSTED_RETRY_SEC: float = 600.0 + # [2026-10-08] DT 고아 잔고 감지 — Portfolio.strategy_type='daytrading' 수량이 있는데 + # 인메모리 포지션·active ActiveTrade(day_trade) 가 없는 종목(예: 10/06 KB금융 EOD 매도 + # 접수 후 0체결 → ActiveTrade 만 closed). close_stale_positions 경로로 전량 청산한다. + DAY_TRADE_ORPHAN_DETECT_ENABLED: bool = True + # DT 청산 반영 직후엔 Portfolio 행·KIS 잔고가 아직 이전 수량일 수 있다 → 이 시간 동안 판정 보류 + DAY_TRADE_ORPHAN_GRACE_SEC: float = 600.0 + # 고아로 판정하려면 이 기간 안에 DT ActiveTrade 행(상태 무관)이 있어야 한다 — 근거 없는 + # 'daytrading' 태그(수동 매수가 섞인 행 등)를 팔지 않게. 매도 수량도 그 행 수량이 상한. + DAY_TRADE_ORPHAN_LOOKBACK_DAYS: int = 7 # 청산 주문 1건 응답 대기 상한. 초과 = 결과 불명 → 당일 주문내역으로 접수 확인. # ⚠️ 이 상한에는 전송 전 대기(분산락·종목 세마포어·토큰)도 포함된다(전송 시점 표식은 # trade_executor/korea_investment_api 쪽 훅이 필요 — 미구현). diff --git a/backend/app/core/heartbeat.py b/backend/app/core/heartbeat.py new file mode 100644 index 0000000..7f23621 --- /dev/null +++ b/backend/app/core/heartbeat.py @@ -0,0 +1,31 @@ +"""스케줄러 사이클 heartbeat 레지스트리 (순수 인메모리, DB/네트워크 없음). + +각 스케줄러는 사이클이 끝날 때 ``beat(name, summary)`` 를 호출한다. +main.py 의 감시 루프와 /health 가 ``snapshot()`` 으로 마지막 사이클 경과시간을 읽는다. +""" +from __future__ import annotations + +import time +from typing import Any, Dict, Optional + +_beats: Dict[str, Dict[str, Any]] = {} + + +def beat(name: str, summary: Optional[str] = None) -> None: + _beats[name] = {"at": time.monotonic(), "summary": summary} + + +def age_seconds(name: str, now: Optional[float] = None) -> Optional[float]: + rec = _beats.get(name) + if rec is None: + return None + return (now if now is not None else time.monotonic()) - rec["at"] + + +def snapshot(now: Optional[float] = None) -> Dict[str, Dict[str, Any]]: + n = now if now is not None else time.monotonic() + return {k: {"age_sec": round(n - v["at"], 1), "summary": v["summary"]} for k, v in _beats.items()} + + +def reset() -> None: + _beats.clear() diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py index 7669abf..0ec51a6 100644 --- a/backend/app/core/redis.py +++ b/backend/app/core/redis.py @@ -14,6 +14,7 @@ Redis 클라이언트 모듈 """ import json import logging +import time from typing import Any, Optional import redis.asyncio as aioredis @@ -33,6 +34,22 @@ TTL_ANALYSIS = 3600 # analysis:{code} 종목별 분석 결과 # ── 싱글턴 클라이언트 ────────────────────────────────────────────────────────── _redis_client: Optional[Redis] = None +# [라운드4] 초기 연결 실패 후 지연 재연결 쓰로틀(초). get_redis() 가 None 일 때만 시도한다. +REDIS_RECONNECT_INTERVAL_SEC = 30.0 +_last_reconnect_attempt: float = 0.0 +_shutting_down: bool = False + + +def _build_client() -> Redis: + """클라이언트 생성(from_url 은 지연 연결이라 네트워크 I/O 없음).""" + return aioredis.from_url( + settings.REDIS_URL, + encoding="utf-8", + decode_responses=True, + socket_timeout=settings.REDIS_SOCKET_TIMEOUT, + socket_connect_timeout=settings.REDIS_SOCKET_CONNECT_TIMEOUT, + ) + async def init_redis() -> None: """FastAPI lifespan startup에서 호출. @@ -40,30 +57,29 @@ async def init_redis() -> None: REDIS_URL 은 settings.REDIS_URL 사용 (기본: redis://localhost:6379/0). 연결 실패 시 _redis_client = None 유지 → 캐시 함수들이 조용히 skip. """ - global _redis_client + global _redis_client, _last_reconnect_attempt, _shutting_down + _shutting_down = False + _last_reconnect_attempt = time.monotonic() try: # [2026-10-02] 소켓 타임아웃 — 이전에는 무제한이라 Redis 가 행(응답 없음)이면 DT 청산 # 마무리·분산락 획득이 무기한 멈췄다. 타임아웃 시 redis.TimeoutError 가 나고, 기존 호출부의 # 예외 처리(캐시 skip·락 실패 경로)로 흘러간다. - client = aioredis.from_url( - settings.REDIS_URL, - encoding="utf-8", - decode_responses=True, - socket_timeout=settings.REDIS_SOCKET_TIMEOUT, - socket_connect_timeout=settings.REDIS_SOCKET_CONNECT_TIMEOUT, - ) + client = _build_client() await client.ping() _redis_client = client _masked_url = settings.REDIS_URL.split("@")[-1] if "@" in settings.REDIS_URL else settings.REDIS_URL logger.info(f"✅ Redis 연결 완료: {_masked_url}") except Exception as e: - logger.warning(f"⚠️ Redis 연결 실패 (캐시 비활성화): {e}") + logger.warning( + f"⚠️ Redis 연결 실패 (캐시 비활성화, {REDIS_RECONNECT_INTERVAL_SEC:.0f}초 간격 지연 재연결): {e}" + ) _redis_client = None async def close_redis() -> None: """FastAPI lifespan shutdown에서 호출.""" - global _redis_client + global _redis_client, _shutting_down + _shutting_down = True if _redis_client: await _redis_client.aclose() _redis_client = None @@ -71,7 +87,25 @@ async def close_redis() -> None: def get_redis() -> Optional[Redis]: - """현재 Redis 클라이언트 반환. 연결 안 됐으면 None.""" + """현재 Redis 클라이언트 반환. + + 초기 연결 실패로 None 이면 REDIS_RECONNECT_INTERVAL_SEC 쓰로틀로 클라이언트를 재생성한다. + 재생성 클라이언트는 연결 미검증(지연 연결)이므로 Redis 가 아직 죽어 있으면 호출부의 + 기존 예외 처리(경고 후 skip)로 흘러가고, 복구되면 자동으로 정상 동작한다. + """ + global _redis_client, _last_reconnect_attempt + if _redis_client is not None or _shutting_down: + return _redis_client + now = time.monotonic() + if now - _last_reconnect_attempt < REDIS_RECONNECT_INTERVAL_SEC: + return None + _last_reconnect_attempt = now + try: + _redis_client = _build_client() + logger.warning("⚠️ Redis 지연 재연결: 클라이언트 재생성(연결은 첫 사용 시 확인)") + except Exception as e: + logger.warning(f"⚠️ Redis 지연 재연결 실패: {e}") + _redis_client = None return _redis_client diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 4e28d59..249b190 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -223,6 +223,27 @@ async def is_valid_credential_v2(token: str | None) -> bool: return (await resolve_credential_v2(token)) is not None +WS_AUTH_SUBPROTOCOL = "autostock.auth" + + +def extract_ws_subprotocol_token(subprotocol_header: str | None) -> tuple[str | None, str]: + """Sec-WebSocket-Protocol 헤더에서 (토큰, 협상할 서브프로토콜) 추출. + + 클라이언트: new WebSocket(url, ["autostock.auth", token]) → + 헤더 "autostock.auth, ". "autostock.auth" 다음 항목이 토큰. + 반환 subprotocol 은 accept(subprotocol=...) 에 넘길 값("" 이면 협상 없음). + 토큰은 쿼리스트링과 달리 프록시 액세스 로그에 남지 않는다. + """ + if not subprotocol_header: + return None, "" + items = [p.strip() for p in subprotocol_header.split(",") if p.strip()] + if WS_AUTH_SUBPROTOCOL not in items: + return None, "" + idx = items.index(WS_AUTH_SUBPROTOCOL) + token = items[idx + 1] if idx + 1 < len(items) else None + return token, WS_AUTH_SUBPROTOCOL + + async def require_api_key(api_key: str | None = Security(_api_key_header)) -> str: """매매 실행, 관리 API 등 민감한 엔드포인트에 필수 적용. diff --git a/backend/app/core/trade_status.py b/backend/app/core/trade_status.py new file mode 100644 index 0000000..8cd8e08 --- /dev/null +++ b/backend/app/core/trade_status.py @@ -0,0 +1,9 @@ +"""TradeHistory.status 집합의 단일 출처 (순수 상수 모듈). + +writer 가 실제로 쓰는 값은 COMPLETED/PARTIAL/PENDING/FAILED 이고 EXECUTED/FILLED 는 +읽는 쪽의 방어적 레거시 값이다. 손익 집계는 "확정 체결"만 세므로 PENDING/FAILED 는 제외한다. +경로마다 허용목록이 달라 두 화면의 합계가 어긋나는 것을 막기 위해 여기로 모은다. +""" + +# 실현손익 집계용 확정 체결 상태 (누적 실현손익 등). 전체 집합의 합집합. +REALIZED_PNL_STATUSES: tuple[str, ...] = ("EXECUTED", "COMPLETED", "FILLED") diff --git a/backend/app/main.py b/backend/app/main.py index 4a20c35..dcc5dd0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,7 +20,7 @@ import pytz from fastapi import Depends from app.core.config import settings -from app.core.security import require_api_key as _require_admin_key +from app.core.security import require_api_key as _require_api_key from app.core.security import require_admin_v2 as _require_admin_v2 from app.api import api_router from app.services.websocket_manager import websocket_manager @@ -72,6 +72,292 @@ def _spawn_background(coro) -> asyncio.Task: return task +# ── 스케줄러 생존/heartbeat 감시 (하드닝 R1) ───────────────────────────── +# 매매 스케줄러(장중 사이클이 있는 것)만 정체 판정 대상. 배치류는 크래시 알림만. +_HEARTBEAT_WATCHED = ("swing", "daytrading") +# config.py 는 동시 수정 중이라 모듈 상수로 둔다(추후 settings 편입 후보) +_FAILED_TRADE_RETENTION_DAYS = 90 +# stock_analysis 는 (stock_code, analysis_date) UNIQUE 라 종목·일자당 마지막 사이클 1행이다. +# 7일 보존은 실전 RSI/ml_signal/score 를 1주 뒤 지워 §8.4 live-vs-backtest 대조와 §8.5-21 ML 점수 +# 분포 측정을 막았다. 일 ~800행 x 400일 = 약 32만 행이라 부담이 없고 조회 쿼리는 모두 일자 조건이다. +_STOCK_ANALYSIS_RETENTION_DAYS = 400 +# db_worker 크래시 자동 재시작 백오프/한도 (_spawn_scheduler_v2 와 동일 정책) +_DB_WORKER_MAX_RESTARTS_PER_HOUR = 5 +_DB_WORKER_RESTART_BACKOFF_SEC = (1.0, 5.0, 15.0, 30.0, 60.0) +_DB_WORKER_LIMIT_COOLDOWN_SEC = 600.0 +_db_worker_restarts: list = [] # 재시작 monotonic 시각 +_db_worker_restart_total = 0 +_db_worker_restart_limit_hit = False +_db_worker_restart_handle = None +_HB_SCHEDULER_MAX_RESTARTS_PER_HOUR = 5 +_HB_SCHEDULER_RESTART_BACKOFF_SEC = (5.0, 10.0, 30.0, 60.0, 60.0) # 재시작 지수 백오프 +_HB_SCHEDULER_LIMIT_COOLDOWN_SEC = 600.0 # 한도 소진 후 재시도 최소 대기 +_HB_KIS_STREAM_SILENT_SECONDS = 600.0 # 장중 WS 무수신 경보 기준 +_restart_handles: dict = {} # name -> call_later 핸들(중복 예약 방지) +_HB_SCHEDULER_STALE_SECONDS = 600.0 +_HB_SCHEDULER_WATCHDOG_INTERVAL_SEC = 60.0 +_HB_SCHEDULER_ALERT_REPEAT_SEC = 1800.0 +_scheduler_tasks: dict[str, str] = {} # name -> app.state 속성명 +_scheduler_restarts: dict[str, list] = {} # name -> 재시작 monotonic 시각들 +_shutting_down = False +_stale_alerted_at: dict[str, float] = {} + + +def _notify_system_warning_bg(category: str, message: str) -> None: + try: + from app.services.telegram_notifier import telegram_notifier + _spawn_background(telegram_notifier.notify_system_warning(category=category, message=message)) + except Exception as _swallowed: + logger.warning("[main] 시스템 경고 텔레그램 실패(%s): %s", category, _swallowed) + + +def _restart_delay_v2(attempt_index: int) -> float: + """재시작 백오프(초). attempt_index 는 최근 1시간 내 재시작 횟수(0부터).""" + seq = _HB_SCHEDULER_RESTART_BACKOFF_SEC + if not seq: + return 0.0 + return float(seq[min(attempt_index, len(seq) - 1)]) + + +def _spawn_scheduler_v2(application, attr: str, name: str, factory): + """스케줄러 태스크 생성 + 종료/크래시 시 알림·백오프 재시작 콜백. + + 한도(시간당 N회) 소진 시에도 영구 포기하지 않고 쿨다운 후 재시도한다(창이 지나면 복구). + """ + _scheduler_tasks[name] = attr + + def _start_task() -> None: + new_task = asyncio.create_task(factory()) + new_task.add_done_callback(_on_done) + setattr(application.state, attr, new_task) + + def _retry(limit_hit: bool) -> None: + _restart_handles.pop(name, None) + if _shutting_down: + return + now = time.monotonic() + hist = [t for t in _scheduler_restarts.get(name, []) if now - t < 3600.0] + if len(hist) >= _HB_SCHEDULER_MAX_RESTARTS_PER_HOUR: + # 아직 창 안 — 가장 오래된 기록이 빠질 때까지(최소 쿨다운) 다시 대기 + wait = max(_HB_SCHEDULER_LIMIT_COOLDOWN_SEC, 3600.0 - (now - hist[0]) + 1.0) + _restart_handles[name] = asyncio.get_running_loop().call_later(wait, _retry, True) + return + hist.append(now) + _scheduler_restarts[name] = hist + if limit_hit: + logger.warning(f"[main] 스케줄러 '{name}' 쿨다운 후 재시작") + _start_task() + + def _on_done(task: asyncio.Task): + if task.cancelled() or _shutting_down: + return + exc = task.exception() + reason = f"{type(exc).__name__}: {exc}" if exc else "예기치 않게 정상 종료" + logger.error(f"🚨 스케줄러 '{name}' 종료: {reason}") + _notify_system_warning_bg("SCHEDULER", f"스케줄러 '{name}' 중단: {reason}") + if name in _restart_handles: + return # 이미 재시작 예약됨 + now = time.monotonic() + hist = [t for t in _scheduler_restarts.get(name, []) if now - t < 3600.0] + loop = asyncio.get_running_loop() + if len(hist) >= _HB_SCHEDULER_MAX_RESTARTS_PER_HOUR: + wait = max(_HB_SCHEDULER_LIMIT_COOLDOWN_SEC, 3600.0 - (now - hist[0]) + 1.0) + logger.error( + f"🚨 스케줄러 '{name}' 재시작 한도 초과 — {wait:.0f}초 후 재시도 예약(수동 확인 권장)" + ) + _notify_system_warning_bg("SCHEDULER", f"'{name}' 재시작 한도 초과 — {wait:.0f}초 후 재시도, 수동 확인 필요") + _restart_handles[name] = loop.call_later(wait, _retry, True) + return + delay = _restart_delay_v2(len(hist)) + logger.warning(f"[main] 스케줄러 '{name}' {delay:.0f}초 후 재시작 예약") + _restart_handles[name] = loop.call_later(delay, _retry, False) + + _start_task() + return getattr(application.state, attr) + + +def _scheduler_health_v2(application, now: float | None = None, market_open: bool | None = None) -> dict: + """스케줄러 생존·사이클 경과시간. stale 은 장중 + heartbeat 보고 스케줄러만 판정.""" + from app.core import heartbeat as _hb + n = now if now is not None else time.monotonic() + if market_open is None: + try: + market_open = bool(is_market_open()) + except Exception as _swallowed: + warn_throttled(logger, "main:hb_mkt", "[health] 장 상태 확인 실패: %s", _swallowed) + market_open = False + stale_limit = _HB_SCHEDULER_STALE_SECONDS + out: dict = {} + for name, attr in _scheduler_tasks.items(): + task = getattr(application.state, attr, None) + alive = bool(task is not None and not task.done()) + age = _hb.age_seconds(name, n) + stale = bool(market_open and name in _HEARTBEAT_WATCHED and age is not None and age > stale_limit) + # 한 번도 beat 못 한 채 죽은 감시대상도 장중엔 정체로 본다(age None 맹점) + dead_silent = bool(market_open and name in _HEARTBEAT_WATCHED and age is None and not alive) + out[name] = { + "alive": alive, + "last_cycle_age": None if age is None else round(age, 1), + "stale": bool(stale or dead_silent), + } + return out + + +# ── KIS 실시간 스트림 건강 감시 (하드닝 R1-3) ──────────────────────────── +_kis_stream_last_msg_mono: float | None = None +_kis_stream_probe_installed = False + + +def _install_kis_stream_probe_v2(stream=None) -> bool: + """kis_stream._enqueue_realtime 을 감싸 마지막 tick/quote 수신 시각을 기록한다. + + kis_realtime_stream.py 를 수정하지 않고 main 에서 관측만 추가(원 동작·인자 불변). + """ + global _kis_stream_probe_installed + if stream is None: + from app.services.kis_realtime_stream import kis_stream as stream + if getattr(stream, "_main_probe_installed", False): + _kis_stream_probe_installed = True + return False + orig = stream._enqueue_realtime + + def _probe(msg_type, stock_code, parsed): + global _kis_stream_last_msg_mono + _kis_stream_last_msg_mono = time.monotonic() + return orig(msg_type, stock_code, parsed) + + stream._enqueue_realtime = _probe + stream._main_probe_installed = True + _kis_stream_probe_installed = True + return True + + +def _on_kis_stream_done_v2(task: asyncio.Task) -> None: + if task.cancelled() or _shutting_down: + return + exc = task.exception() + reason = f"{type(exc).__name__}: {exc}" if exc else "예기치 않게 정상 종료" + logger.error(f"🚨 KIS 실시간 스트림 태스크 종료: {reason}") + _notify_system_warning_bg("KIS_STREAM", f"KIS 실시간 스트림 종료: {reason} (시세 푸시 정지, 매매는 REST 사용)") + + +def _kis_stream_health_v2(application, now: float | None = None, market_open: bool | None = None) -> dict | None: + """스트림 비활성이면 None. 장중 무수신이 임계 초과면 silent=True.""" + task = getattr(application.state, "kis_stream_task", None) + if task is None: + return None + n = now if now is not None else time.monotonic() + if market_open is None: + try: + market_open = bool(is_market_open()) + except Exception as _swallowed: + warn_throttled(logger, "main:ks_mkt", "[health] 장 상태 확인 실패: %s", _swallowed) + market_open = False + alive = not task.done() + last = _kis_stream_last_msg_mono + age = None if last is None else n - last + limit = float(_HB_KIS_STREAM_SILENT_SECONDS) + # 장중 + 태스크 사망, 또는 장중 + (수신 이력 있고 임계 초과) + silent = bool(market_open and (not alive or (age is not None and age > limit))) + return {"alive": alive, "last_msg_age": None if age is None else round(age, 1), "silent": silent} + + +def _db_worker_health_v2(application) -> dict: + task = getattr(application.state, "db_worker_task", None) + try: + from app.services.db_worker import get_db_task_queue + qsize = get_db_task_queue().qsize() + except Exception as _swallowed: + warn_throttled(logger, "main:hb_q", "[health] db 큐 크기 확인 실패: %s", _swallowed) + qsize = -1 + return { + "alive": bool(task is not None and not task.done()), + "qsize": qsize, + "restarts_total": _db_worker_restart_total, + "restart_limit_hit": _db_worker_restart_limit_hit, + } + + +def _db_worker_restart_plan_v2(now: float) -> tuple[float, bool]: + """크래시 후 재시작 대기(초)와 한도 소진 여부. 최근 1시간 재시작 이력을 갱신하지 않는다.""" + hist = [t for t in _db_worker_restarts if now - t < 3600.0] + if len(hist) >= _DB_WORKER_MAX_RESTARTS_PER_HOUR: + return max(_DB_WORKER_LIMIT_COOLDOWN_SEC, 3600.0 - (now - hist[0]) + 1.0), True + seq = _DB_WORKER_RESTART_BACKOFF_SEC + return (float(seq[min(len(hist), len(seq) - 1)]) if seq else 0.0), False + + +def _on_db_worker_done_v2(application, task: asyncio.Task, factory) -> None: + """db_worker 종료 콜백: 백오프 후 재시작, 시간당 한도 초과 시 error+/health 승격 후 쿨다운 재시도.""" + global _db_worker_restart_handle, _db_worker_restart_limit_hit, _db_worker_restart_total + if task.cancelled() or _shutting_down: + return + exc = task.exception() + if not exc: + return + if _db_worker_restart_handle is not None: + return # 이미 재시작 예약됨 + wait, limit_hit = _db_worker_restart_plan_v2(time.monotonic()) + _db_worker_restart_limit_hit = limit_hit + if limit_hit: + logger.error(f"🚨 db_worker 재시작 한도 초과: {type(exc).__name__}: {exc} — {wait:.0f}초 후 재시도(수동 확인 필요)") + else: + logger.error(f"db_worker 크래시: {type(exc).__name__}: {exc}, {wait:.1f}초 후 자동 재시작...") + _notify_system_warning_bg( + "DB_WORKER", + f"db_worker 크래시({'한도 초과, ' if limit_hit else ''}{wait:.0f}초 후 재시작): {type(exc).__name__}: {exc}", + ) + + def _restart() -> None: + global _db_worker_restart_handle, _db_worker_restart_total + _db_worker_restart_handle = None + if _shutting_down: + return + _db_worker_restarts[:] = [t for t in _db_worker_restarts if time.monotonic() - t < 3600.0] + _db_worker_restarts.append(time.monotonic()) + _db_worker_restart_total += 1 + new_task = asyncio.create_task(factory()) + new_task.add_done_callback(lambda t: _on_db_worker_done_v2(application, t, factory)) + application.state.db_worker_task = new_task + + _db_worker_restart_handle = asyncio.get_running_loop().call_later(wait, _restart) + + +async def _heartbeat_watchdog_v2(application): + """60초마다 장중 정체/사망 스케줄러를 점검해 텔레그램 경보(30분 간격 재경보).""" + interval = _HB_SCHEDULER_WATCHDOG_INTERVAL_SEC + realert = _HB_SCHEDULER_ALERT_REPEAT_SEC + while True: + await asyncio.sleep(interval) + try: + h = _scheduler_health_v2(application) + now = time.monotonic() + for name, info in h.items(): + if not info["stale"]: + _stale_alerted_at.pop(name, None) + continue + if now - _stale_alerted_at.get(name, -1e9) < realert: + continue + _stale_alerted_at[name] = now + logger.error(f"🚨 스케줄러 '{name}' 장중 정체: 마지막 사이클 {info['last_cycle_age']}초 전") + _notify_system_warning_bg( + "SCHEDULER", f"'{name}' 장중 사이클 정체 {info['last_cycle_age']}초 (alive={info['alive']})" + ) + ks = _kis_stream_health_v2(application) + if ks is None or not ks["silent"]: + _stale_alerted_at.pop("kis_stream", None) + elif now - _stale_alerted_at.get("kis_stream", -1e9) >= realert: + _stale_alerted_at["kis_stream"] = now + logger.error(f"🚨 KIS 스트림 장중 무수신: {ks}") + _notify_system_warning_bg( + "KIS_STREAM", f"KIS 실시간 스트림 장중 무수신 {ks['last_msg_age']}초 (alive={ks['alive']})" + ) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning(f"[heartbeat] 감시 루프 오류: {exc}") + + async def _reload_bans_after_replay() -> None: """replay_dlq 가 밴 upsert 를 재큐잉했으면 큐 소진을 기다린 뒤 loss_tracker 를 다시 로드.""" try: @@ -126,6 +412,88 @@ async def _await_background_tasks(timeout: float) -> None: await asyncio.gather(*still, return_exceptions=True) +async def _drain_all_background_tasks_v2(timeout: float) -> None: + """[하드닝 R2 2026-10-09] 종료 시 main `_background_tasks` 와 trade_executor 추적 태스크를 + 하나의 timeout 예산 안에서 동시에 기다린다. db_worker 정지 **전에** 호출해야 한다.""" + from app.services import trade_executor as _te + + results = await asyncio.gather( + _await_background_tasks(timeout), + _te.drain_tracked_tasks_v2(timeout), + _drain_order_path_tasks_v2(timeout), + return_exceptions=True, + ) + for r in results: + if isinstance(r, BaseException) and not isinstance(r, asyncio.CancelledError): + logger.warning(f"[shutdown] 백그라운드 태스크 drain 실패: {r!r}") + + +# 종료 시 기다려야 하는 주문 경로 내부 코루틴(asyncio.shield 안쪽 — 외부에서 참조 보관 없음) +_ORDER_PATH_CORO_NAMES = frozenset({ + "_place_and_finalize_exit_guarded_impl", # DT 청산: 주문 전송 ~ 상태 반영 + "_place_and_record_entry_impl", # DT 진입: 주문 전송 ~ 상태 반영 +}) + + +def _collect_order_path_tasks_v2() -> list: + """종료 drain 대상 주문 경로 태스크: 스윙 _inflight_steps + DT 체결 보정 + shield 내부 태스크.""" + found: dict[int, asyncio.Task] = {} + try: + from app.services.swing import scheduler as _sw + for t in list(getattr(_sw, "_inflight_steps", {}).values()): + found[id(t)] = t + except Exception as e: + logger.warning(f"[shutdown] 스윙 진행 중 단계 수집 실패: {e}") + try: + from app.services.daytrading import fill_reconciler as _fr + for t in list(getattr(_fr, "_pending_tasks", ())): + found[id(t)] = t + except Exception as e: + logger.warning(f"[shutdown] DT 체결 보정 태스크 수집 실패: {e}") + try: + cur = asyncio.current_task() + for t in asyncio.all_tasks(): + if t is cur: + continue + coro = t.get_coro() + if getattr(coro, "__name__", "") in _ORDER_PATH_CORO_NAMES: + found[id(t)] = t + except Exception as e: + logger.warning(f"[shutdown] shield 내부 태스크 수집 실패: {e}") + loop = asyncio.get_running_loop() + return [t for t in found.values() if not t.done() and t.get_loop() is loop] + + +def _task_label_v2(t) -> str: + coro = t.get_coro() + return getattr(coro, "__qualname__", None) or getattr(coro, "__name__", None) or repr(t) + + +async def _drain_order_path_tasks_v2(timeout: float) -> int: + """[하드닝 R3] 주문 경로 태스크를 같은 예산으로 기다리고, 미완료는 cancel + error + 텔레그램. + 취소한 개수를 반환. KIS 클라이언트·엔진 종료 **전에** 호출해야 한다.""" + pending = _collect_order_path_tasks_v2() + if not pending: + return 0 + _done, still = await asyncio.wait(pending, timeout=max(0.0, float(timeout))) + if not still: + return 0 + labels = sorted(_task_label_v2(t) for t in still) + logger.error( + "[shutdown] 주문 경로 태스크 %d건이 %.0f초 내 미완료 — cancel (주문 결과 불명·장부 미반영 가능, " + "KIS 주문내역/잔고 수동 확인 필요): %s", len(still), float(timeout), labels, + ) + _notify_system_warning_bg( + "SHUTDOWN_ORDER_TASKS", + f"종료 중 주문 경로 태스크 {len(still)}건 미완료 취소 — 주문내역·잔고 수동 확인: {', '.join(labels)[:300]}", + ) + for t in still: + t.cancel() + await asyncio.gather(*still, return_exceptions=True) + await _await_background_tasks(3.0) # 방금 띄운 텔레그램 알림이 전송될 시간 + return len(still) + + # Import scheduler functions from dedicated module from app.services.schedulers import ( batch_analysis_scheduler, @@ -137,6 +505,90 @@ from app.services.schedulers import ( ) +# ── [하드닝 R6] alembic 버전 대조 (기동 가드 H1 보강) ───────────────────────── +# H1 가드는 portfolio.user_id 하나만 본다. 0012(데이터 마이그레이션)·이후 리비전 누락은 +# 무경보 기동이 되므로 DB alembic_version 과 코드 head 를 대조해 /health 에 노출한다. +_migration_state: dict = {"checked": False, "db": None, "head": None, "mismatch": False} + + +def _code_alembic_heads_v2() -> list: + """코드(레포) 의 alembic head 리비전 목록. 읽을 수 없으면 예외.""" + from alembic.config import Config + from alembic.script import ScriptDirectory + cfg = Config() + cfg.set_main_option( + "script_location", + os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "alembic"), + ) + return sorted(ScriptDirectory.from_config(cfg).get_heads()) + + +def _compare_migration_v2(db_versions, heads) -> bool: + """True = 불일치. 둘 다 집합으로 비교(다중 head 도 허용).""" + return set(db_versions or []) != set(heads or []) + + +async def _check_migration_head_v2(conn) -> dict: + """DB alembic_version(읽기 전용 SELECT) 과 코드 head 를 대조하고 상태를 갱신한다.""" + from sqlalchemy import text as _t + heads = _code_alembic_heads_v2() + rows = (await conn.execute(_t("SELECT version_num FROM alembic_version"))).fetchall() + db_versions = sorted(str(r[0]) for r in rows) + mismatch = _compare_migration_v2(db_versions, heads) + _migration_state.update( + checked=True, db=",".join(db_versions) or None, head=",".join(heads) or None, mismatch=mismatch, + ) + return dict(_migration_state) + + +_MIGRATION_RECHECK_INTERVAL_SEC = 300.0 + + +def _install_migration_entry_guard_v2(mgr) -> None: + """마이그레이션 불일치 동안 신규 진입을 차단한다(SECURITY_FAIL_CLOSED 와 무관). + + 모든 매수 경로가 `mdd_manager.can_open_new_position()` 을 await 하므로 인스턴스 속성으로 + 감싼다(mdd_manager.py 시그니처 불변). 청산 경로는 이 메서드를 호출하지 않아 영향 없음. + _migration_state 는 모듈 전역을 매 호출 조회하므로 재대조로 불일치가 풀리면 자동 해제된다. + """ + if getattr(mgr, "_migration_guard_installed", False): + return + _orig = mgr.can_open_new_position + + async def _guarded() -> bool: + if _migration_state.get("mismatch"): + return False + return await _orig() + + mgr.can_open_new_position = _guarded + mgr._migration_guard_installed = True + + +async def _migration_recheck_loop_v2(interval_sec: float = None, iterations: int = None) -> None: + """불일치가 남아 있는 동안 주기적으로 재대조하고 critical 로그+텔레그램을 반복한다. + `alembic upgrade head` 후엔 자동으로 정상 복귀(진입 차단 해제).""" + interval = _MIGRATION_RECHECK_INTERVAL_SEC if interval_sec is None else interval_sec + n = 0 + while iterations is None or n < iterations: + n += 1 + await asyncio.sleep(interval) + was_mismatch = bool(_migration_state.get("mismatch")) + try: + async with engine.connect() as _c: + st = await _check_migration_head_v2(_c) + except Exception as exc: + logger.warning(f"⚠️ [Migration] 재대조 실패(불일치 상태는 유지): {exc}") + continue + if st["mismatch"]: + msg = (f"DB alembic_version={st['db']} ≠ 코드 head={st['head']} — 신규 진입 차단 중. " + "`alembic upgrade head` 실행 필요.") + logger.critical(f"❌ [Migration] {msg}") + _notify_system_warning_bg("migration_mismatch", msg) + elif was_mismatch: + logger.warning("✅ [Migration] alembic 버전 일치 복구 — 신규 진입 차단 해제 " + "(strategy_cfg 캐시는 최대 30초 내 갱신)") + + @asynccontextmanager async def lifespan(app: FastAPI): """애플리케이션 시작/종료 시 실행될 코드""" @@ -195,6 +647,29 @@ async def lifespan(app: FastAPI): "Migration required: portfolio.user_id column missing. Run `alembic upgrade head` before starting the app." ) + # [하드닝 R6] alembic_version ↔ 코드 head 대조. 대조 불가(alembic 디렉터리/테이블 부재)는 + # 불일치로 단정하지 않고 warning 만 남긴다. 불일치는 critical+텔레그램+/health degraded, + # SECURITY_FAIL_CLOSED=True(DEBUG=False) 면 기동 중단. + _mig = None + try: + async with engine.connect() as _mig_conn: + _mig = await _check_migration_head_v2(_mig_conn) + except Exception as _mig_exc: + logger.warning(f"⚠️ [Migration] alembic 버전 대조 불가(추정: alembic 디렉터리/테이블 없음): {_mig_exc}") + if _mig is not None and _mig["mismatch"]: + _mig_msg = ( + f"DB alembic_version={_mig['db']} ≠ 코드 head={_mig['head']} — " + "`alembic upgrade head` 를 먼저 실행하세요(전략 파라미터 캐시가 낡은 값으로 채워질 수 있음)." + ) + logger.critical(f"❌ [Migration] {_mig_msg}") + _notify_system_warning_bg("migration_mismatch", _mig_msg) + if settings.SECURITY_FAIL_CLOSED and not settings.DEBUG: + raise RuntimeError("SECURITY_FAIL_CLOSED=True: 마이그레이션 불일치 — " + _mig_msg) + # 기동은 허용하되 신규 진입은 차단 + 주기 재경고(R8). 가드 설치는 try 밖 단순 대입. + from app.services.mdd_manager import mdd_manager as _mdd_for_guard + _install_migration_entry_guard_v2(_mdd_for_guard) + app.state.migration_recheck_task = asyncio.create_task(_migration_recheck_loop_v2()) + try: from app.services.loss_tracker import loss_tracker async with AsyncSessionLocal() as session: @@ -212,13 +687,13 @@ async def lifespan(app: FastAPI): try: from app.models.models import StockAnalysis, TradeHistory, Portfolio - from sqlalchemy import delete + from sqlalchemy import delete, select, func from datetime import datetime, timedelta async with AsyncSessionLocal() as session: _kst = pytz.timezone("Asia/Seoul") _today_kst = datetime.now(_kst).date() - cutoff_date = _today_kst - timedelta(days=7) + cutoff_date = _today_kst - timedelta(days=_STOCK_ANALYSIS_RETENTION_DAYS) result = await session.execute( delete(StockAnalysis).where(StockAnalysis.analysis_date < cutoff_date) ) @@ -232,7 +707,19 @@ async def lifespan(app: FastAPI): # 조건이라 오래된 PENDING 이 남아도 매매 판정에 영향이 없다. # FAILED(주문 거부, 주식 미보유)만 1일 경과분을 정리한다 — 기동 시 1회의 # 부트스트랩 정리이며 실시간 계층이 아니다. - cutoff_datetime = _today_kst - timedelta(days=1) + # [하드닝 R2] 보존 90일 + 삭제 전 일자·사유별 건수 로그 — 1일 보존은 '왜 매수 0건' + # 조사 근거(거부 빈도·사유)를 재기동마다 지웠다. + cutoff_datetime = _today_kst - timedelta(days=_FAILED_TRADE_RETENTION_DAYS) + try: + _summ = await session.execute( + select(func.date(TradeHistory.created_at), TradeHistory.message, func.count()) + .where(TradeHistory.status == 'FAILED', TradeHistory.created_at < cutoff_datetime) + .group_by(func.date(TradeHistory.created_at), TradeHistory.message) + ) + for _d, _m, _c in _summ.all(): + logger.info(f"[cleanup] FAILED 삭제 예정 {_d} x{_c}: {str(_m)[:80]}") + except Exception as _se: + logger.warning(f"[cleanup] FAILED 요약 조회 실패: {type(_se).__name__}: {_se}") result = await session.execute( delete(TradeHistory).where( TradeHistory.status == 'FAILED', @@ -324,31 +811,30 @@ async def lifespan(app: FastAPI): # ── MDD 상태 복원 (Redis 영속화) ──────────────────────────────────────── try: from app.services.mdd_manager import mdd_manager - restored = await mdd_manager.load_from_redis() - if not restored: - logger.info("ℹ️ MDD Redis 상태 없음 — 초기 상태로 시작") + from app.services.mdd_state_backup import ( + mdd_backup_loop_v2, restore_mdd_state_v2, + ) + _mdd_src = await restore_mdd_state_v2(mdd_manager) + if _mdd_src == "file": + logger.warning("⚠️ MDD Redis 상태 없음 — 파일 백업에서 peak/플래그 복원 (peak=%s)", f"{mdd_manager.peak_balance:,.0f}") + await mdd_manager.save_to_redis() + elif _mdd_src == "none": + logger.warning("🚨 MDD 상태 복원 실패(Redis·파일 모두 없음) — peak 가 현재 잔고로 재시드되어 낙폭 이력이 소실될 수 있음") + try: + from app.services.telegram_notifier import telegram_notifier + _spawn_background(telegram_notifier.notify_system_warning( + category="MDD_STATE", + message="MDD 상태(Redis/파일) 복원 실패 — peak 재시드, 낙폭 이력 소실 가능. 수동 확인 필요", + )) + except Exception as _tg: + logger.warning("[main] MDD 복원 실패 텔레그램 알림 실패: %s", _tg) + app.state.mdd_backup_task = asyncio.create_task(mdd_backup_loop_v2(mdd_manager)) except Exception as e: - logger.warning(f"⚠️ MDD Redis 복원 실패 (non-critical): {e}") + logger.warning(f"⚠️ MDD 상태 복원 실패: {e}") # 워커 + 스케줄러 시작 (C5: 자동 재시작 콜백 포함) def _restart_db_worker(task: asyncio.Task): - if task.cancelled(): - return - exc = task.exception() - if exc: - logger.error(f"db_worker 크래시: {exc}, 자동 재시작...") - # Telegram 시스템 경고 - try: - from app.services.telegram_notifier import telegram_notifier - asyncio.ensure_future(telegram_notifier.notify_system_warning( - category="DB_WORKER", - message=f"db_worker 크래시 후 자동 재시작: {type(exc).__name__}: {exc}", - )) - except Exception as _swallowed: - logger.warning("[main] db_worker 재시작 텔레그램 알림 실패: %s", _swallowed) - new_task = asyncio.create_task(db_writer_worker()) - new_task.add_done_callback(_restart_db_worker) - app.state.db_worker_task = new_task + _on_db_worker_done_v2(app, task, db_writer_worker) app.state.db_worker_task = asyncio.create_task(db_writer_worker()) app.state.db_worker_task.add_done_callback(_restart_db_worker) @@ -389,14 +875,16 @@ async def lifespan(app: FastAPI): # ── 스윙 / 데이트레이딩 분리 스케줄러 (신규 아키텍처) ──────────────────── from app.services.swing.scheduler import swing_scheduler from app.services.daytrading.scheduler import daytrading_scheduler - app.state.swing_trade_task = asyncio.create_task(swing_scheduler()) - app.state.day_trade_task = asyncio.create_task(daytrading_scheduler()) + # [하드닝 R1] done_callback(크래시 알림·제한적 재시작) + heartbeat 감시 + _spawn_scheduler_v2(app, "swing_trade_task", "swing", swing_scheduler) + _spawn_scheduler_v2(app, "day_trade_task", "daytrading", daytrading_scheduler) # 배치 분석 / 일봉 수집 / 장 후 분석 (기존 유지) - app.state.batch_analysis_task = asyncio.create_task(batch_analysis_scheduler()) - app.state.krx_collector_task = asyncio.create_task(krx_data_collector_scheduler()) - app.state.post_market_task = asyncio.create_task(post_market_analyzer_scheduler()) - app.state.journal_intraday_task = asyncio.create_task(journal_intraday_scheduler()) + _spawn_scheduler_v2(app, "batch_analysis_task", "batch_analysis", batch_analysis_scheduler) + _spawn_scheduler_v2(app, "krx_collector_task", "krx_collector", krx_data_collector_scheduler) + _spawn_scheduler_v2(app, "post_market_task", "post_market", post_market_analyzer_scheduler) + _spawn_scheduler_v2(app, "journal_intraday_task", "journal_intraday", journal_intraday_scheduler) + app.state.heartbeat_watchdog_task = asyncio.create_task(_heartbeat_watchdog_v2(app)) # ── 미국장 자동매매 스케줄러 (US_TRADING_ENABLED=True 시 활성화) ────────── if getattr(settings, 'US_TRADING_ENABLED', False): @@ -527,18 +1015,27 @@ async def lifespan(app: FastAPI): if settings.KIS_WS_ENABLED: from app.services.kis_realtime_stream import run_kis_realtime_stream app.state.kis_stream_task = asyncio.create_task(run_kis_realtime_stream()) + _install_kis_stream_probe_v2() + app.state.kis_stream_task.add_done_callback(_on_kis_stream_done_v2) logger.info("📡 KIS 실시간 스트림 태스크 시작 (KIS_WS_ENABLED=True)") else: logger.info("⏭️ KIS 실시간 스트림 비활성화됨 (KIS_WS_ENABLED=False in .env)") app.state.kis_stream_task = None yield + global _shutting_down + _shutting_down = True + for _h in list(_restart_handles.values()): + _h.cancel() + _restart_handles.clear() # 태스크 정리 # [H-5 2026-10-02] db_worker 는 **마지막에** 멈춘다. 이전에는 생산자(us_trade·news· # adaptive·ai_strategy 등)보다 먼저 cancel 돼, 그 뒤 생산자가 종료 과정에서 넣은 쓰기가 # drain 되지 못하고 유실됐다. 생산자를 모두 멈춘 뒤 워커가 큐를 비우게 한다. for task_attr in ( + "heartbeat_watchdog_task", + "migration_recheck_task", "swing_trade_task", "batch_analysis_task", "day_trade_task", @@ -551,6 +1048,7 @@ async def lifespan(app: FastAPI): "ml_retrain_task", "pre_market_us_task", "adaptive_strategy_task", + "mdd_backup_task", "ai_strategy_task", "__background__", "db_worker_task", @@ -559,7 +1057,11 @@ async def lifespan(app: FastAPI): # [M-D 2026-10-02] 워커를 멈추기 전에 fire-and-forget 태스크가 enqueue 를 끝낼 # 시간을 준다(상한 SHUTDOWN_BACKGROUND_DRAIN_SECONDS). 넘기면 cancel — # 그 뒤 늦게 들어오는 enqueue 는 db_worker 가 DLQ 로 직접 보관한다. - await _await_background_tasks(float(settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS)) + # [하드닝 R2 2026-10-09] trade_executor 의 추적 태스크(체결 확인·미체결 지정가 + # 취소·진입가 보정)도 **같은 예산 안에서 동시에** 기다린다(직렬로 붙이면 종료 + # 예산이 SHUTDOWN_BACKGROUND_DRAIN_SECONDS 만큼 늘어 stop_grace_period 40s 를 넘는다). + # 넘기면 drain_tracked_tasks_v2 가 cancel + "수동 확인 필요" warning 을 남긴다. + await _drain_all_background_tasks_v2(float(settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS)) continue try: task = getattr(app.state, task_attr, None) @@ -755,6 +1257,110 @@ async def health_check(): return value +_DB_WRITE_STALL_SEC = 300.0 # 큐에 적체가 있는데 마지막 쓰기 성공이 이보다 오래되면 정체 +_BATCH_FAIL_CYCLES_DEGRADED = 2 # 배치 분석 연속 실패 사이클 수 (이상이면 degraded) + + +def _runtime_health_v2(market_open: bool | None = None) -> tuple[dict, list]: + """스윙 청산·MDD fail-safe·적응형·체결보정·뉴스 헬스를 모아 (상세, degraded 사유 코드) 반환. + + 각 getter 실패는 해당 항목만 'unavailable' 로 표시하고 warning 한다(헬스 자체는 죽이지 않는다). + """ + detail: dict = {} + reasons: list = [] + if market_open is None: + try: + market_open = bool(is_market_open()) + except Exception as _swallowed: + warn_throttled(logger, "main:rt_mkt", "[health] 장 상태 확인 실패: %s", _swallowed) + market_open = False + try: + from app.services.swing.scheduler import get_swing_scheduler_health_v2, get_mdd_update_health + sw = get_swing_scheduler_health_v2() + detail["swing"] = sw + reasons.extend(str(r) for r in (sw.get("degraded_reasons") or [])) + mdd = get_mdd_update_health() + detail["mdd"] = mdd + if mdd.get("blind_block_active"): + reasons.append("mdd_blind_block") + except Exception as _swallowed: + warn_throttled(logger, "main:rt_swing", "[health] 스윙/MDD 헬스 조회 실패: %s", _swallowed) + detail["swing"] = "unavailable" + reasons.append("swing_health_unavailable") + try: + ks = _kis_stream_health_v2(app, market_open=market_open) + if ks is not None: + detail["kis_stream"] = ks + if ks["silent"]: + reasons.append("kis_stream_silent") + except Exception as _swallowed: + warn_throttled(logger, "main:rt_ks", "[health] KIS 스트림 헬스 조회 실패: %s", _swallowed) + detail["kis_stream"] = "unavailable" + try: + from app.services import adaptive_strategy as _adp + age = _adp.get_last_success_age_sec_v2() + detail["adaptive"] = {"last_success_age": round(age, 1)} + if market_open and age > float(_adp._STALE_LIMIT_SEC): + reasons.append("adaptive_stale") + except Exception as _swallowed: + warn_throttled(logger, "main:rt_adp", "[health] 적응형 헬스 조회 실패: %s", _swallowed) + detail["adaptive"] = "unavailable" + try: + from app.services.swing.fill_sweep import get_fill_sweep_health_v2 + fs = get_fill_sweep_health_v2() + detail["fill_sweep"] = fs + if int(fs.get("exhausted") or 0) > 0: + reasons.append("fill_sweep_exhausted") + except Exception as _swallowed: + warn_throttled(logger, "main:rt_fs", "[health] 체결보정 헬스 조회 실패: %s", _swallowed) + detail["fill_sweep"] = "unavailable" + # [R4] 헬스 getter 편입 — db_worker 회로/쓰기 정체, 배치·KRX 수집 정체, 국면 소스 차단 + try: + from app.services.db_worker import get_worker_health_v2 + wh = get_worker_health_v2() + detail["db_worker_state"] = wh + if wh.get("circuit_open"): + reasons.append("db_circuit_open") + _lst = wh.get("last_success_ts") + if (int(wh.get("qsize") or 0) > 0 and _lst is not None + and time.time() - float(_lst) > _DB_WRITE_STALL_SEC): + reasons.append("db_write_stalled") + except Exception as _swallowed: + warn_throttled(logger, "main:rt_dbw", "[health] DB 워커 상태 조회 실패: %s", _swallowed) + detail["db_worker_state"] = "unavailable" + reasons.append("db_worker_health_unavailable") + try: + from app.services.schedulers import get_batch_analysis_health_v2, get_krx_data_health_v2 + bh = get_batch_analysis_health_v2() + detail["batch_analysis"] = bh + if int(bh.get("consecutive_failed_cycles") or 0) >= _BATCH_FAIL_CYCLES_DEGRADED: + reasons.append("batch_analysis_failing") + kh = get_krx_data_health_v2() + detail["krx_data"] = kh + if kh.get("ok") is False: + reasons.append("krx_data_incomplete") + except Exception as _swallowed: + warn_throttled(logger, "main:rt_batch", "[health] 배치/KRX 헬스 조회 실패: %s", _swallowed) + detail["batch_analysis"] = "unavailable" + reasons.append("batch_health_unavailable") + try: + from app.services.market_analyzer import get_regime_source_health + rh = get_regime_source_health() + detail["regime_source"] = rh + if market_open and rh.get("blocked"): + reasons.append("regime_blocked") + except Exception as _swallowed: + warn_throttled(logger, "main:rt_regime", "[health] 국면 소스 헬스 조회 실패: %s", _swallowed) + detail["regime_source"] = "unavailable" + try: + from app.services.news_sentiment import get_news_collect_health + detail["news"] = get_news_collect_health() + except Exception as _swallowed: + warn_throttled(logger, "main:rt_news", "[health] 뉴스 헬스 조회 실패: %s", _swallowed) + detail["news"] = "unavailable" + return detail, sorted(set(reasons)) + + async def _health_check_impl(): from sqlalchemy import text as _sa_text from app.core.redis import get_redis @@ -778,6 +1384,10 @@ async def _health_check_impl(): warn_throttled(logger, "main:626", "[health] Redis ping 실패: %s", _swallowed) status = "healthy" if (db_ok and redis_ok) else "degraded" + sched_info = _scheduler_health_v2(app) + dbw_info = _db_worker_health_v2(app) + if (not dbw_info["alive"]) or any(v["stale"] or not v["alive"] for v in sched_info.values()): + status = "degraded" # [M-5] 키 설정 경고 — 상세 사유는 공개하지 않는다(기동 로그 error 참조) from app.core.security import security_key_issues # [L1] DLQ 건수만(내용 비공개). manual = 사람이 확인해야 하는 커밋 여부 불명 쓰기 @@ -788,15 +1398,139 @@ async def _health_check_impl(): except Exception as _swallowed: warn_throttled(logger, "main:health_dlq", "[health] DLQ 건수 확인 실패: %s", _swallowed) dlq_info = {"pending": -1, "manual": -1} + _rt_detail, rt_reasons = _runtime_health_v2() + if dlq_info["manual"] > 0: # 사람이 확인해야 하는 커밋 여부 불명 쓰기 + rt_reasons = sorted(set(rt_reasons) | {"dlq_manual"}) + if _migration_state.get("mismatch"): + rt_reasons = sorted(set(rt_reasons) | {"migration_mismatch"}) + if rt_reasons: + status = "degraded" return { "status": status, "db": "ok" if db_ok else "error", "redis": "ok" if redis_ok else "error", "security": "warn" if security_key_issues() else "ok", "dlq": dlq_info, + "schedulers": sched_info, + "db_worker": dbw_info, + # 사유 코드만 공개(수치·오류문구 비공개). 상세는 /health/detail (API 키 필요) + "degraded_reasons": rt_reasons, } +@app.get("/health/detail", dependencies=[Depends(_require_admin_v2)]) +async def health_detail(): + """스윙 청산·MDD·적응형·체결보정·뉴스 상세 헬스 (관리자 전용, 캐시 없음).""" + detail, reasons = _runtime_health_v2() + return {"degraded_reasons": reasons, **detail} + + +# ── WebSocket 세션 재검증 (하드닝 R3) ───────────────────────────────────── +_WS_RECHECK_INTERVAL_SEC = 300.0 # is_active 재확인 주기(security 30초 캐시를 경유) +_ws_owners: dict[str, str] = {} # client_id -> 인증 주체(username) + + +def _ws_owner_may_connect_v2(client_id: str, owner: str, active_connections) -> bool: + """이미 다른 인증 주체가 쓰는 client_id 로는 접속(=기존 연결 교체)할 수 없다.""" + if client_id not in active_connections: + return True + prev = _ws_owners.get(client_id) + return prev is None or prev == owner + + +def _ws_token_exp_v2(token: str): + """세션 JWT 의 exp(epoch 초). API 키 등 exp 가 없으면 None. 서명은 핸드셰이크에서 이미 검증됨.""" + try: + from jose import jwt as _jwt + exp = _jwt.get_unverified_claims(token).get("exp") + return float(exp) if exp is not None else None + except Exception: + return None + + +async def _ws_session_still_valid_v2(token: str, exp_ts, recheck: bool) -> bool: + """exp 초과면 False. recheck 면 resolve_credential_v2(30초 캐시)로 is_active 재확인. + + 조회 실패(503 등)는 연결을 유지한다 — 일시 장애로 정상 사용자를 끊지 않는다(다음 주기에 재시도). + """ + if exp_ts is not None and time.time() >= exp_ts: + return False + if not recheck: + return True + from app.core.security import resolve_credential_v2 + try: + return (await resolve_credential_v2(token)) is not None + except Exception as e: + logger.warning(f"[WS] 세션 재확인 조회 실패 — 연결 유지: {e}") + return True + + +# ── WS 하드닝 R5: 재확인 연속 실패 상한 / get_chart 횟수 제한 / 구독 코드 상한 ── +_WS_RECHECK_MAX_FAILURES = 3 # 재확인 조회가 연속 이만큼 실패하면 연결 종료(핸드셰이크 fail-closed 와 정합) +_WS_MAX_SUBSCRIBE_CODES = 41 # KIS 실시간 등록 한도 근처 — 그 이상은 잘라낸다 +_WS_CHART_PER_SEC = 2 # get_chart 연결당 초당 상한 +_WS_CHART_PER_MIN = 30 # get_chart 연결당 분당 상한 + + +def _ws_cap_codes_v2(codes) -> list: + """구독 코드 목록을 중복 제거·공백 제거 후 _WS_MAX_SUBSCRIBE_CODES 로 자른다.""" + out: list = [] + seen = set() + for c in codes or []: + c = str(c).strip() + if not c or c in seen: + continue + seen.add(c) + out.append(c) + if len(out) >= _WS_MAX_SUBSCRIBE_CODES: + break + return out + + +class _WsChartLimiterV2: + """연결 단위 슬라이딩 윈도우 제한 (초당/분당). allow() 가 False 면 요청을 버린다.""" + + def __init__(self, per_sec: int, per_min: int, clock=time.monotonic): + self._per_sec = int(per_sec) + self._per_min = int(per_min) + self._clock = clock + self._hits: list = [] + + def allow(self) -> bool: + now = self._clock() + self._hits = [t for t in self._hits if now - t < 60.0] + if len(self._hits) >= self._per_min: + return False + if sum(1 for t in self._hits if now - t < 1.0) >= self._per_sec: + return False + self._hits.append(now) + return True + + +async def _ws_session_still_valid_v3(token: str, exp_ts, recheck: bool, state: dict) -> bool: + """v2 와 동일하되 재확인 조회 연속 실패가 _WS_RECHECK_MAX_FAILURES 회에 이르면 False. + + 일시 장애(연속 실패 < 상한)는 유지하고, 장기 DB 장애 중 비활성화된 계정은 끊는다. + 성공하면 카운터를 리셋한다. state 는 연결별 dict({"fails": int}). + """ + if exp_ts is not None and time.time() >= exp_ts: + return False + if not recheck: + return True + from app.core.security import resolve_credential_v2 + try: + ok = (await resolve_credential_v2(token)) is not None + except Exception as e: + state["fails"] = int(state.get("fails", 0)) + 1 + if state["fails"] >= int(_WS_RECHECK_MAX_FAILURES): + logger.warning(f"[WS] 세션 재확인 연속 {state['fails']}회 실패 — 연결 종료: {e}") + return False + logger.warning(f"[WS] 세션 재확인 조회 실패({state['fails']}/{_WS_RECHECK_MAX_FAILURES}) — 연결 유지: {e}") + return True + state["fails"] = 0 + return ok + + @app.websocket("/ws/{client_id}") async def websocket_endpoint(websocket: WebSocket, client_id: str): """WebSocket 연결 엔드포인트. @@ -810,26 +1544,47 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): {"type": "unsubscribe", "stock_codes": [...]} → ACK {"type": "set_subscriptions", "stock_codes": [...]} → ACK {"type": "get_chart", "stock_code": "...", "period": "D", "limit": 200} - → {"type": "chart_data", ...} + → {"type": "chart_data", "stock_code", "period", + "data", "is_sample"(항상 False), "reason", "error"} + (백그라운드 응답 — 수신 루프 비차단) 그 외 메시지 → echo """ # WebSocket 인증: 운영 환경에서는 토큰 검증 (API_KEY/SECRET_KEY 또는 로그인 세션 토큰 허용) + # 토큰 전달: Sec-WebSocket-Protocol("autostock.auth", ) 우선, ?token= 은 하위호환(추후 제거). + from app.core.security import extract_ws_subprotocol_token + _sp_token, _ws_subprotocol = extract_ws_subprotocol_token( + websocket.headers.get("sec-websocket-protocol") + ) + _ws_token = "" + _ws_owner = "debug" if not settings.DEBUG: - from app.core.security import is_valid_credential_v2 - token = websocket.query_params.get("token", "") + from app.core.security import resolve_credential_v2 + _ws_token = _sp_token or websocket.query_params.get("token", "") # [H-2] 세션 사용자의 is_active 까지 확인 (revoke 후 재연결 차단) # DB-READ-EXCEPTION: 핸드셰이크 1회, 캐시 미스일 때만 users SELECT(commit 없음) — # security._fetch_session_user_flags_impl. 연결 후 메시지 루프에서는 DB 를 읽지 않는다. try: - _ws_ok = bool(token) and await is_valid_credential_v2(token) + _cred = await resolve_credential_v2(_ws_token) if _ws_token else None except Exception as _ws_auth_err: # 사용자 상태 조회 불가(503) — 접속 거부(재연결로 회복) logger.warning(f"[WS] 인증 상태 확인 실패 — 접속 거부: {_ws_auth_err}") - _ws_ok = False - if not _ws_ok: + _cred = None + if _cred is None: await websocket.close(code=4001, reason="Unauthorized") return + _ws_owner = _cred.username + # [하드닝 R3] client_id 는 인증 주체에 묶는다 — 다른 사용자의 client_id 로 접속해 + # 그 연결을 교체(connect_v2 는 같은 client_id 를 무조건 교체)하는 것을 막는다. + if not _ws_owner_may_connect_v2(client_id, _ws_owner, websocket_manager.active_connections): + logger.warning(f"[WS] client_id={client_id} 는 다른 인증 주체 소유 — 접속 거부(user={_ws_owner})") + await websocket.close(code=4003, reason="Forbidden") + return + _ws_exp = _ws_token_exp_v2(_ws_token) if _ws_token else None + _ws_last_recheck = time.monotonic() + _ws_recheck_state = {"fails": 0} + _chart_limiter = _WsChartLimiterV2(_WS_CHART_PER_SEC, _WS_CHART_PER_MIN) - await websocket_manager.connect(websocket, client_id) + await websocket_manager.connect_v2(websocket, client_id, subprotocol=_ws_subprotocol or None) + _ws_owners[client_id] = _ws_owner # 연결 즉시 보유종목 자동 구독 (set_subscriptions 누락 대비 폴백) async def _auto_subscribe_portfolio(cid: str): @@ -847,13 +1602,45 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): logger.warning(f"[auto-sub] 보유종목 자동 구독 실패: {e}") _spawn_background(_auto_subscribe_portfolio(client_id)) + def _notify_kis_subscription_change() -> None: + # [H-5] 구독 변경 즉시 KIS 동기화 태스크를 깨운다 (수신 프레임 유무와 무관) + from app.services.kis_realtime_stream import kis_stream + kis_stream.notify_subscription_change() + + # [M-6] get_chart 는 백그라운드 태스크로 응답 — 수신 루프(ping/구독 처리)를 막지 않는다. + chart_tasks: dict = {} + + async def _send_chart_response(code: str, period, limit_raw) -> None: + from app.api.endpoints.market_data import build_ws_chart_response_v2 + resp = await build_ws_chart_response_v2(code, period, limit_raw) + if websocket_manager.active_connections.get(client_id) is not websocket: + return # 그 사이 연결이 교체/종료됨 + logger.info(f"[get_chart] {code}/{period} → {len(resp['data'])} candles" + f"{' reason=' + str(resp['reason']) if resp.get('reason') else ''} → {client_id}") + try: + await websocket.send_text(_dumps(resp)) + except Exception as send_err: + logger.warning(f"[get_chart] {client_id} 응답 전송 실패: {send_err}") + try: while True: # 이 핸들러의 websocket이 active_connections에서 교체되었으면 종료 if websocket_manager.active_connections.get(client_id) is not websocket: - logger.debug(f"Client {client_id}: 교체된 연결 감지, 이전 핸들러 종료") + logger.info(f"Client {client_id}: 교체/해제된 연결 감지, 핸들러 종료") break + # [하드닝 R3] 핸드셰이크 이후 세션 만료·계정 비활성화 재확인 + if not settings.DEBUG: + _now_m = time.monotonic() + _due = (_now_m - _ws_last_recheck) >= float(_WS_RECHECK_INTERVAL_SEC) + if _due: + _ws_last_recheck = _now_m + if not await _ws_session_still_valid_v3(_ws_token, _ws_exp, _due, _ws_recheck_state): + logger.warning(f"[WS] {client_id} 세션 만료/비활성 — 연결 종료(4001)") + with contextlib.suppress(Exception): + await websocket.close(code=4001, reason="Session expired") + break + try: data = await asyncio.wait_for(websocket.receive_text(), timeout=60.0) except asyncio.TimeoutError: @@ -871,6 +1658,8 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): except Exception: break continue + if not isinstance(msg, dict): + msg = {} msg_type = msg.get("type", "") @@ -882,8 +1671,9 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): continue if msg_type == "subscribe": - codes = [str(c) for c in msg.get("stock_codes", [])] + codes = _ws_cap_codes_v2(msg.get("stock_codes") or []) websocket_manager.subscribe(client_id, codes) + _notify_kis_subscription_change() await websocket_manager.send_personal_message( _dumps({ "type": "ack", "action": "subscribe", @@ -895,8 +1685,9 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): continue if msg_type == "unsubscribe": - codes = [str(c) for c in msg.get("stock_codes", [])] + codes = _ws_cap_codes_v2(msg.get("stock_codes") or []) websocket_manager.unsubscribe(client_id, codes) + _notify_kis_subscription_change() await websocket_manager.send_personal_message( _dumps({ "type": "ack", "action": "unsubscribe", @@ -908,12 +1699,15 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): continue if msg_type == "set_subscriptions": - codes = [str(c) for c in msg.get("stock_codes", [])] + codes = _ws_cap_codes_v2(msg.get("stock_codes") or []) logger.info(f"[WS] {client_id}: set_subscriptions → {codes}") websocket_manager.set_subscriptions(client_id, codes) + # 선택 종목을 명시하면(선택 필드) KIS 등록 우선순위·호가 대상에 반영 + if "selected_code" in msg: + _sel = str(msg.get("selected_code") or "").strip() + websocket_manager.set_selected_codes_v2(client_id, [_sel] if _sel else []) # 구독 목록 변경 → KIS 스트림에 즉시 동기화 요청 - from app.services.kis_realtime_stream import kis_stream - kis_stream.notify_subscription_change() + _notify_kis_subscription_change() try: await websocket.send_text(_dumps({ "type": "ack", "action": "set_subscriptions", @@ -925,69 +1719,80 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): # 구독 종목 차트 캐시 사전 워밍업 (백그라운드, 논블로킹) if codes: async def _warmup_chart_cache(stock_codes: list): - from app.api.endpoints.market_data import get_chart_data_cached, chart_cache - from datetime import datetime + from app.api.endpoints.market_data import ( + get_chart_data_cached_v2, is_chart_cache_fresh_v2, + ) for code in stock_codes: - cached = chart_cache.get(f"{code}__D__200") - if cached and (datetime.now() - cached["timestamp"]).total_seconds() < 600: + # 엔트리 자체 ttl(장중 합성 30초 / 그 외 10분) 존중 + if is_chart_cache_fresh_v2(code, "D", 200): continue try: - await get_chart_data_cached(code, "D", 200) - logger.debug(f"[warmup] {code} 차트 캐시 완료") + await get_chart_data_cached_v2(code, "D", 200) except Exception as e: warn_throttled(logger, "main:767", f"[warmup] {code} 캐시 실패: {e}") _spawn_background(_warmup_chart_cache(codes)) continue if msg_type == "get_chart": - stock_code = str(msg.get("stock_code", "")).strip() + stock_code = str(msg.get("stock_code", "") or "").strip() period = msg.get("period", "D") - limit = int(msg.get("limit", 200)) + limit_raw = msg.get("limit", 200) if not stock_code: continue - # 자동 구독 폴백: 클라이언트에 구독 종목이 없으면 요청 종목 자동 구독 - current_subs = websocket_manager.get_subscriptions(client_id) - if stock_code not in current_subs: + if not _chart_limiter.allow(): + logger.warning(f"[get_chart] {client_id} 요청 빈도 초과 — 무시: {stock_code}") + continue + # 차트를 보는 종목 = 선택 종목 (KIS 등록 우선순위·호가 구독 대상, H-4) + websocket_manager.set_selected_codes_v2(client_id, [stock_code]) + # 자동 구독 폴백: 요청 종목이 구독 목록에 없으면 추가 + if stock_code not in websocket_manager.get_subscriptions(client_id): websocket_manager.subscribe(client_id, [stock_code]) - from app.services.kis_realtime_stream import kis_stream - kis_stream.notify_subscription_change() logger.info(f"[auto-sub] {client_id}: get_chart 요청으로 {stock_code} 자동 구독 추가") - try: - from app.api.endpoints.market_data import get_chart_data_cached - chart_data = await get_chart_data_cached(stock_code, period, limit) - logger.info(f"[get_chart] {stock_code}/{period} → {len(chart_data)} candles → sending to {client_id}") - await websocket.send_text( - _dumps({"type": "chart_data", "stock_code": stock_code, "period": period, "data": chart_data}), - ) - except Exception as e: - logger.error(f"get_chart 오류 ({stock_code}): {e}") - try: - await websocket.send_text( - _dumps({"type": "chart_data", "stock_code": stock_code, "period": period, "data": [], "error": str(e)}), - ) - except Exception: - break + _notify_kis_subscription_change() + task_key = (stock_code, str(period), str(limit_raw)) + prev = chart_tasks.get(task_key) + if prev is not None and not prev.done(): + continue # 동일 요청 처리 중 — 그 응답이 곧 간다 + task = _spawn_background(_send_chart_response(stock_code, period, limit_raw)) + chart_tasks[task_key] = task + task.add_done_callback( + lambda t, k=task_key: chart_tasks.pop(k, None) if chart_tasks.get(k) is t else None + ) continue await websocket_manager.send_personal_message(f"Echo: {data}", client_id) except WebSocketDisconnect as e: - # 동일 client_id로 새 연결이 교체되었을 경우, 이전 핸들러가 disconnect하면 새 연결까지 제거됨 - # 현재 websocket이 active_connections에 있는 것과 동일한 경우에만 disconnect - if websocket_manager.active_connections.get(client_id) is websocket: - websocket_manager.disconnect(client_id) logger.info(f"Client {client_id} disconnected (code={e.code})") except Exception as e: - if websocket_manager.active_connections.get(client_id) is websocket: - websocket_manager.disconnect(client_id) logger.error(f"Client {client_id} unexpected WS error: {type(e).__name__}: {e}") + finally: + # [L-2] break 경로 포함 모든 종료에서 정리. 동일 client_id 로 새 연결이 교체됐다면 + # 새 연결까지 제거하지 않도록 현재 websocket 일 때만 disconnect. + for _t in list(chart_tasks.values()): + _t.cancel() + chart_tasks.clear() + if websocket_manager.active_connections.get(client_id) is websocket: + _ws_owners.pop(client_id, None) + websocket_manager.disconnect(client_id) + try: + _notify_kis_subscription_change() + except Exception as _notify_err: + logger.warning(f"[WS] {client_id} 종료 후 KIS 구독 동기화 요청 실패: {_notify_err}") @app.post("/api/admin/reset-daily-loss", dependencies=[Depends(_require_admin_v2)]) -async def reset_daily_loss(api_key: str = Depends(_require_admin_key)): - """테스트용 일일 손실 한도 강제 리셋 (인증 필수)""" - from app.services.mdd_manager import mdd_manager - await mdd_manager.reset() - return {"status": "ok", "message": "daily loss limit reset"} +async def reset_daily_loss(api_key: str = Depends(_require_api_key)): + """DT 당일 실현손익(일일 손실 한도 기준) 리셋 (인증 필수). + + [하드닝 R2] 예전에는 mdd_manager.reset() 을 불러 MDD peak 를 현재 잔고로 낮춰 + -10%/-20% 낙폭 기준선을 조용히 소거했다. MDD 리셋은 /risk/mdd/reset 에서만 한다. + """ + from app.services.daytrading.position_manager import day_trading_state + async with day_trading_state.trade_lock: + prev = day_trading_state.daily_pnl + day_trading_state.daily_pnl = 0.0 + logger.warning(f"[admin] DT 일일 손익 리셋: {prev:+,.0f} -> 0 (MDD 는 건드리지 않음)") + return {"status": "ok", "message": "daytrading daily pnl reset (MDD untouched)", "previous_daily_pnl": prev} if __name__ == "__main__": diff --git a/backend/app/services/adaptive_strategy.py b/backend/app/services/adaptive_strategy.py index 91d5a97..868f7c4 100644 --- a/backend/app/services/adaptive_strategy.py +++ b/backend/app/services/adaptive_strategy.py @@ -581,6 +581,91 @@ _last_update_ts: float = 0.0 _CACHE_TTL = 120 # 2분 _update_lock = asyncio.Lock() +# [R2 2026-10-09] 갱신 실패가 이어지면 직전 모드(Normal/Aggressive)가 무기한 유지돼 +# Crisis 진입 차단이 fail-open 이 되는 결함 대응: 마지막 "성공" 갱신 시각을 추적하고 +# 장중 _STALE_LIMIT_SEC 초과 시 Defensive 로 강등 + 텔레그램 경보. +_STALE_LIMIT_SEC = 600.0 # 10분 (갱신 주기 2분 × 5회) +_STALE_ALERT_INTERVAL_SEC = 1800.0 +_last_success_ts: float = time.monotonic() # 기동 시각을 기준선으로(기동 후 계속 실패해도 감지) +_last_stale_alert_ts: float = 0.0 +_bg_tasks: set = set() # fire-and-forget 태스크 참조 보관(GC 방지) + + +def _market_open_or_true_v2() -> bool: + """장중 여부. 판정 실패 시 True(강등 쪽이 안전).""" + try: + from app.core.market_schedule import is_market_open + return bool(is_market_open()) + except Exception as e: + warn_throttled(logger, "adaptive_strategy:stale_mkt", "[Adaptive] 장 상태 판정 실패 — 장중으로 간주: %s", e) + return True + + +def _spawn_bg_v2(coro) -> None: + try: + task = asyncio.ensure_future(coro) + except Exception: + coro.close() + raise + _bg_tasks.add(task) + task.add_done_callback(_bg_tasks.discard) + + +def get_last_success_age_sec_v2() -> float: + """마지막 성공 갱신 이후 경과(초). /health 노출용.""" + return time.monotonic() - _last_success_ts + + +def enforce_staleness_v2() -> Optional["AdaptiveParams"]: + """갱신이 _STALE_LIMIT_SEC 넘게 실패 중이면(장중) Defensive 로 강등하고 경보한다. + + 이미 Defensive/Crisis 면 모드는 건드리지 않는다(더 보수적인 쪽 유지). + 현재 파라미터를 반환한다. + """ + global _current_params, _last_stale_alert_ts, _last_update_ts + + age = get_last_success_age_sec_v2() + if age <= _STALE_LIMIT_SEC or not _market_open_or_true_v2(): + return _current_params + + cur = _current_params + if cur is not None and cur.trading_mode in (TradingMode.CRISIS.value, TradingMode.DEFENSIVE.value): + degraded = False + else: + import pytz as _pytz + _current_params = AdaptiveParams( + trading_mode=TradingMode.DEFENSIVE.value, + condition=MarketCondition(trading_mode=TradingMode.DEFENSIVE.value), + mdd_current=cur.mdd_current if cur is not None else 0.0, + updated_at=datetime.now(_pytz.timezone("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S"), + fallback_used=False, + **_MODE_PARAMS[TradingMode.DEFENSIVE.value], + ) + _last_update_ts = time.monotonic() + degraded = True + + now = time.monotonic() + if degraded or now - _last_stale_alert_ts >= _STALE_ALERT_INTERVAL_SEC: + _last_stale_alert_ts = now + logger.error( + "[Adaptive] 🚨 갱신 %.0f초째 실패(상한 %.0fs) — %s", + age, _STALE_LIMIT_SEC, + "Defensive 로 강등" if degraded else "모드 유지(이미 보수적)", + ) + try: + from app.services.telegram_notifier import telegram_notifier + _spawn_bg_v2(telegram_notifier.notify_system_warning( + category="adaptive_stale", + message=(f"[적응형 전략] 모드 갱신 {age / 60:.0f}분째 실패 " + f"(상한 {_STALE_LIMIT_SEC / 60:.0f}분). " + f"{'Defensive 로 강등' if degraded else '현재 모드 유지'}. 시세/KIS 점검 필요"), + )) + except RuntimeError: + pass # 실행 중인 루프 없음(동기 컨텍스트) — 로그 error 로 충분 + except Exception as e: + logger.warning("[Adaptive] 경보 전송 실패: %s", e) + return _current_params + async def _save_to_redis(condition: MarketCondition, params: AdaptiveParams) -> None: """Redis에 현재 상태 저장.""" @@ -606,6 +691,18 @@ async def _save_to_redis(condition: MarketCondition, params: AdaptiveParams) -> logger.warning("[Adaptive] Redis 저장 실패: %s", e) +async def _save_data_source_v2(src: str, bars: int) -> None: + """사용한 데이터 소스·봉수를 Redis 에 기록(진단용).""" + try: + from app.core.redis import get_redis + from app.core.json_helpers import _dumps + client = get_redis() + if client: + await client.set("adaptive:data_source", _dumps({"src": src, "bars": bars}), ex=_REDIS_TTL) + except Exception as e: + logger.warning("[Adaptive] 데이터 소스 Redis 기록 실패: %s", e) + + async def _record_mode_change(prev_mode: str, new_mode: str, condition: MarketCondition) -> None: """모드 변경 이력을 Redis 리스트에 기록 + Telegram 알림. @@ -667,6 +764,41 @@ async def _record_mode_change(prev_mode: str, new_mode: str, condition: MarketCo ) +_MIN_BARS_FOR_ATR_PCT = 120 # ATR 백분위 산출 최소 봉수(미달 시 갱신 실패) + + +async def _fetch_kodex_chart_v2(): + """KODEX200 일봉(newest-first)과 소스명을 반환. 네이버 → KIS 날짜범위 순. + + 각 소스가 _MIN_BARS_FOR_ATR_PCT 미만이면 다음 소스로 넘어간다. 예외는 warning. + """ + best, best_src = None, "none" + try: + from app.services.naver_finance import get_naver_daily_chart + naver_data = await get_naver_daily_chart("069500", days=400) + if naver_data and len(naver_data) >= _MIN_BARS_FOR_ATR_PCT: + return list(reversed(naver_data)), "naver" + if naver_data: + best, best_src = list(reversed(naver_data)), "naver" + except Exception as _swallowed: + warn_throttled(logger, "adaptive_strategy:643", "[Adaptive] 네이버 KODEX200 차트 조회 실패(KIS 폴백 진행): %s", _swallowed) + try: + from datetime import timedelta + from app.services.korea_investment_api import ( + korea_investment_api, real_market_api as _real_market_api, + ) + _api = _real_market_api or korea_investment_api + _end = datetime.now() + rows = await _api.get_daily_ohlcv_range( + "069500", (_end - timedelta(days=400)).strftime("%Y%m%d"), _end.strftime("%Y%m%d")) + if rows and (best is None or len(rows) > len(best)): + best, best_src = list(rows), "kis_range" + except Exception as _kis_err: + warn_throttled(logger, "adaptive_strategy:kis_range", "[Adaptive] KIS KODEX200 범위 조회 실패: %s", _kis_err) + return best, best_src + + + async def refresh_adaptive_params() -> AdaptiveParams: """시장 데이터를 조회하여 적응형 파라미터를 갱신한다. @@ -677,35 +809,22 @@ async def refresh_adaptive_params() -> AdaptiveParams: - main.py 스케줄러에서 2분 주기 호출 - API 엔드포인트에서 수동 호출 가능 """ - global _current_params, _current_condition, _last_update_ts + global _current_params, _current_condition, _last_update_ts, _last_success_ts async with _update_lock: try: - # KODEX200 일봉 데이터 가져오기 (네이버 우선, KIS 폴백) - chart_data = None - try: - from app.services.naver_finance import get_naver_daily_chart - # ATR 백분위 산출 히스토리 확보 — 캘린더일 기준 400일 요청(≈270거래일, 백테스트와 정합) - naver_data = await get_naver_daily_chart("069500", days=400) - if naver_data and len(naver_data) >= 60: - chart_data = list(reversed(naver_data)) # newest first -> list - except Exception as _swallowed: - warn_throttled(logger, "adaptive_strategy:643", "[Adaptive] 네이버 KODEX200 차트 조회 실패(KIS 폴백 진행): %s", _swallowed) - - if chart_data is None: - from app.services.korea_investment_api import ( - korea_investment_api, real_market_api as _real_market_api, + # KODEX200 일봉 데이터 가져오기 (네이버 우선, KIS 날짜범위 폴백) + # [R3 2026-10-09] ATR 백분위 최소 봉수(_MIN_BARS_FOR_ATR_PCT) 미달이면 갱신 실패로 + # 처리한다(공식이 5일/전체 변동비로 조용히 바뀌어 Crisis 오발동하던 결함). + chart_data, _src = await _fetch_kodex_chart_v2() + if not chart_data or len(chart_data) < _MIN_BARS_FOR_ATR_PCT: + logger.warning( + "[Adaptive] KODEX200 데이터 부족(src=%s, bars=%d < %d) — 갱신 실패 처리, staleness 경로", + _src, len(chart_data or []), _MIN_BARS_FOR_ATR_PCT, ) - _api = _real_market_api or korea_investment_api - kospi_chart = await _api.get_daily_chart("069500", "D", limit=120) - if "output2" in kospi_chart and kospi_chart["output2"]: - chart_data = kospi_chart["output2"] - - if not chart_data or len(chart_data) < 20: - logger.warning("[Adaptive] KODEX200 데이터 부족 — fallback 유지") if _current_params is None: _current_params = _get_fallback_params() - return _current_params + return enforce_staleness_v2() or _current_params # 데이터 파싱 (chart_data는 newest-first 형식) closes = [float(item.get("stck_clpr", 0)) for item in chart_data] @@ -786,12 +905,15 @@ async def refresh_adaptive_params() -> AdaptiveParams: _current_params = new_params _current_condition = condition _last_update_ts = time.monotonic() + _last_success_ts = _last_update_ts # Redis 저장 await _save_to_redis(condition, new_params) + await _save_data_source_v2(_src, len(chart_data)) logger.info( - "[Adaptive] 갱신 완료: mode=%s, ATR=%.0f%%, ADX=%.1f, 5d=%+.2f%%", + "[Adaptive] 갱신 완료: src=%s bars=%d mode=%s, ATR=%.0f%%, ADX=%.1f, 5d=%+.2f%%", + _src, len(chart_data), new_params.trading_mode, condition.atr_percentile, condition.adx_value, @@ -804,7 +926,7 @@ async def refresh_adaptive_params() -> AdaptiveParams: logger.error("[Adaptive] 갱신 실패: %s", e, exc_info=True) if _current_params is None: _current_params = _get_fallback_params() - return _current_params + return enforce_staleness_v2() or _current_params def get_current_adaptive_params() -> AdaptiveParams: @@ -831,12 +953,13 @@ def get_current_adaptive_params() -> AdaptiveParams: # 유발할 수 있으므로 get_running_loop()를 우선 사용하고, 실행 중인 루프가 # 없으면(RuntimeError) 백그라운드 갱신을 조용히 스킵한다. asyncio.get_running_loop() - asyncio.ensure_future(refresh_adaptive_params()) + _spawn_bg_v2(refresh_adaptive_params()) except RuntimeError: pass # 실행 중인 이벤트 루프 없음 — 동기 컨텍스트에서 호출된 경우 except Exception as _swallowed: warn_throttled(logger, "adaptive_strategy:767", "[Adaptive] 백그라운드 갱신 예약 실패: %s", _swallowed) - return _current_params + # 낡은 값을 무기한 반환하지 않도록 staleness 강등 적용(try 밖) + return enforce_staleness_v2() or _current_params # 캐시 없음 — fallback # [FIX] _last_update_ts를 갱신하지 않으면 다음 호출에서 age가 (monotonic()-0.0)으로 diff --git a/backend/app/services/ai_strategy_service.py b/backend/app/services/ai_strategy_service.py index 82ed9ee..46f1741 100644 --- a/backend/app/services/ai_strategy_service.py +++ b/backend/app/services/ai_strategy_service.py @@ -42,6 +42,20 @@ _DT_CANDIDATE_TOP_N = 20 # 데이트레이딩 후보 종목 수 _DT_CANDIDATE_BATCH = 10 # 데이트레이딩 MCP 배치 단위 +def _dedupe_latest_per_stock_v2(analyses, limit: int) -> list: + """입력 순서(score 내림차순)를 유지하며 종목당 첫 행만 남기고 limit 개로 자른다.""" + seen = set() + out = [] + for a in analyses: + if a.stock_code in seen: + continue + seen.add(a.stock_code) + out.append(a) + if len(out) >= limit: + break + return out + + # ============================================================================= # A. 장 전 배치 분석 # ============================================================================= @@ -73,18 +87,35 @@ async def pre_market_analysis() -> dict: # ── 1) DB에서 최근 분석 상위 50종목 조회 ── top_stocks: List[Dict[str, Any]] = [] try: - from sqlalchemy import select, desc + from sqlalchemy import select, desc, func from app.models.models import StockAnalysis async with AsyncSessionLocal() as db: - stmt = ( - select(StockAnalysis) - .where(StockAnalysis.score.isnot(None)) - .order_by(desc(StockAnalysis.score), desc(StockAnalysis.analysis_date)) - .limit(_PRE_MARKET_TOP_N) - ) - rows = await db.execute(stmt) - analyses = rows.scalars().all() + # 최신 분석일(<= 오늘) 하나만 사용: 며칠 전 스냅샷이 상위를 차지하는 것을 방지 + latest_date = ( + await db.execute( + select(func.max(StockAnalysis.analysis_date)).where( + StockAnalysis.score.isnot(None), + StockAnalysis.analysis_date <= date.today(), + ) + ) + ).scalar() + if latest_date is None: + analyses = [] + else: + stmt = ( + select(StockAnalysis) + .where( + StockAnalysis.score.isnot(None), + StockAnalysis.analysis_date == latest_date, + ) + .order_by(desc(StockAnalysis.score), desc(StockAnalysis.id)) + .limit(_PRE_MARKET_TOP_N * 3) + ) + rows = await db.execute(stmt) + analyses = _dedupe_latest_per_stock_v2( + rows.scalars().all(), _PRE_MARKET_TOP_N + ) for a in analyses: top_stocks.append({ diff --git a/backend/app/services/analysis_db_service.py b/backend/app/services/analysis_db_service.py index 711c558..3c336bf 100644 --- a/backend/app/services/analysis_db_service.py +++ b/backend/app/services/analysis_db_service.py @@ -16,7 +16,7 @@ DELETE → INSERT 사이의 race condition 을 원자적으로 해결. from __future__ import annotations import logging -from datetime import date, datetime, timezone +from datetime import date from typing import TYPE_CHECKING from sqlalchemy import select @@ -24,6 +24,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import AsyncSessionLocal +from app.core.timeutil import now_kst from app.models.models import StockAnalysis logger = logging.getLogger(__name__) @@ -89,7 +90,7 @@ def to_analysis_dict( "ml_signal": result.ml_signal, "atr_14": result.atr_14, "adx_14": result.adx_14, - "updated_at": datetime.now(timezone.utc).replace(tzinfo=None), + "updated_at": now_kst(), } diff --git a/backend/app/services/correlation_filter.py b/backend/app/services/correlation_filter.py index 58d4eb4..a3dc602 100644 --- a/backend/app/services/correlation_filter.py +++ b/backend/app/services/correlation_filter.py @@ -122,8 +122,8 @@ _STOCK_CODE_SECTOR_MAP: Dict[str, str] = { "068270": "bio_pharma", # 셀트리온 "326030": "bio_pharma", # SK바이오팜 "128940": "bio_pharma", # 한미약품 - "012450": "bio_pharma", # 한화시스템(바이오 아님, 방산 — 추후 분리 가능) - "091990": "bio_pharma", # 셀트리온헬스케어 + # [하드닝 R1] 012450(한화에어로스페이스·방산, 바이오 아님)·091990(상장폐지)은 제거 — + # sector_mapper 네이버 업종 캐시로 위임한다. # 에너지 "096770": "energy", # SK이노베이션 "010950": "energy", # S-Oil @@ -133,7 +133,7 @@ _STOCK_CODE_SECTOR_MAP: Dict[str, str] = { "055550": "finance", # 신한지주 "086790": "finance", # 하나금융지주 "316140": "finance", # 우리금융지주 - "003550": "finance", # LG + # [하드닝 R1] 003550(LG, 지주회사)은 금융이 아니므로 제거 — sector_mapper 위임. "032830": "finance", # 삼성생명 # 2차전지 / 배터리 "006400": "battery", # 삼성SDI @@ -233,6 +233,7 @@ async def get_stock_sector( if db is not None: sector = await _query_db(db) else: + # DB-READ-EXCEPTION: Portfolio.sector SELECT 전용(commit/add 없음), §3.2 읽기 예외 async with AsyncSessionLocal() as session: sector = await _query_db(session) @@ -291,8 +292,11 @@ async def check_sector_concentration( # 보유 종목들의 섹터 집계 # (_cap_unmapped=False면 미매핑 보유분은 계수에서 제외 — 기존 동작) + # 진입 종목 자신은 제외(추가매수 시 자기 자신을 섹터 카운트에 넣지 않는다) sector_counts: Dict[str, int] = {} for holding in portfolio_holdings: + if holding.stock_code == stock_code: + continue if holding.quantity > 0: h_sector = await get_stock_sector(holding.stock_code, is_domestic=True) if not h_sector: @@ -311,6 +315,24 @@ async def check_sector_concentration( return True, None +_MIN_CORR_BARS = 20 +_corr_event_counts: Dict[str, int] = {} + + +def _count_corr_event(name: str) -> None: + _corr_event_counts[name] = _corr_event_counts.get(name, 0) + 1 + + +def get_correlation_event_counts() -> Dict[str, int]: + """상관 필터 우회/실패 이벤트 누적 카운터 스냅샷 (읽기 전용).""" + return dict(_corr_event_counts) + + +def _switch_on(name: str) -> bool: + """settings 불리언 스위치. 정확히 True 일 때만 켜짐(목/미정의 값은 꺼짐).""" + return getattr(settings, name, False) is True + + async def calculate_portfolio_correlation( stock_code: str, portfolio_holdings: List[Portfolio], @@ -338,68 +360,93 @@ async def calculate_portfolio_correlation( 3. 피어슨 상관계수 계산 4. 최대값이 임계값 초과하면 거부 """ + # 진입 종목 자신(추가매수)은 비교 대상에서 제외 — 자기 상관 1.0 으로 전부 거부되는 것 방지 + portfolio_holdings = [h for h in (portfolio_holdings or []) if h.stock_code != stock_code] if not portfolio_holdings or all(h.quantity <= 0 for h in portfolio_holdings): # 포트폴리오 공시면 상관계수 필터 패스 return True, "portfolio_empty", None - try: - # 신규 종목 일봉 데이터 수집 - new_stock_prices = await _get_daily_prices(stock_code, lookback_days) - if not new_stock_prices or len(new_stock_prices) < 20: - logger.warning(f"[CORRELATION] {stock_code}: 가격 데이터 부족 ({len(new_stock_prices) if new_stock_prices else 0}일)") - return True, "insufficient_data", None + # [하드닝 R1] 예외를 삼키지 않는다 — 호출부(trade_executor/buy_guards)가 예외를 + # fail-closed 로 처리한다. 데이터 부족/계산 불가는 통과시키되 error 로그+카운터로 가시화. + new_series = await _get_daily_prices_v2(stock_code, lookback_days) + if not new_series or len(new_series) < _MIN_CORR_BARS: + _count_corr_event("insufficient_data") + logger.error( + f"[CORRELATION] {stock_code}: 가격 데이터 부족 " + f"({len(new_series) if new_series else 0}일 < {_MIN_CORR_BARS}) — 상관 필터 우회(통과)" + ) + if _switch_on("CORRELATION_FAIL_CLOSED_ON_CANDIDATE_DATA"): + _count_corr_event("candidate_data_fail_closed") + return False, "correlation_data_unavailable", None + return True, "insufficient_data", None - # 포트폴리오 종목 중 가장 높은 상관계수 찾기 - max_correlation = None - max_corr_stock = None + new_map = dict(new_series) + max_correlation = None + max_corr_stock = None - for holding in portfolio_holdings: - if holding.quantity <= 0: - continue + # [R5] 보유종목 시계열: 한 세션 IN 쿼리로 일괄 선적재 + TTL 캐시(후보마다 재조회 방지) + _held_codes = [h.stock_code for h in portfolio_holdings if h.quantity > 0] + await _prefetch_daily_prices_v3(_held_codes, lookback_days) + _holding_data_missing: List[str] = [] - # 포트폴리오 종목의 일봉 데이터 수집 - port_prices = await _get_daily_prices(holding.stock_code, lookback_days) - if not port_prices or len(port_prices) < 20: - logger.debug(f"[CORRELATION] {holding.stock_code}: 가격 데이터 부족") - continue + for holding in portfolio_holdings: + if holding.quantity <= 0: + continue + port_series = await _get_daily_prices_cached_v3(holding.stock_code, lookback_days) + if not port_series or len(port_series) < _MIN_CORR_BARS: + _holding_data_missing.append(holding.stock_code) + _count_corr_event("holding_insufficient_data") + warn_throttled(logger, "correlation_filter:holding_insufficient", + f"[CORRELATION] {holding.stock_code}: 보유종목 가격 데이터 부족 — 비교 제외") + continue - # 공통 기간 데이터만 선택 - common_length = min(len(new_stock_prices), len(port_prices)) - if common_length < 20: - continue + # 날짜 inner-join: 양쪽 모두 존재하는 거래일만 사용 + port_map = dict(port_series) + # 종가 0/결측(<=0) 날짜는 양쪽에서 함께 제외 — 한쪽만 건너뛰면 수익률 길이가 + # 어긋나 비교가 조용히 생략된다(fail-open 확대). + common_dates = sorted(d for d in (set(new_map) & set(port_map)) + if new_map[d] > 0 and port_map[d] > 0) + if len(common_dates) < _MIN_CORR_BARS: + _count_corr_event("common_dates_insufficient") + warn_throttled(logger, "correlation_filter:common_insufficient", + f"[CORRELATION] {stock_code}/{holding.stock_code}: 공통 거래일 " + f"{len(common_dates)}일 < {_MIN_CORR_BARS} — 비교 제외") + continue - new_prices = new_stock_prices[-common_length:] - port_prices = port_prices[-common_length:] + new_returns = _calculate_returns([new_map[d] for d in common_dates]) + port_returns = _calculate_returns([port_map[d] for d in common_dates]) + if not new_returns or len(new_returns) != len(port_returns): + continue - # 수익률 계산 - new_returns = _calculate_returns(new_prices) - port_returns = _calculate_returns(port_prices) + corr = _calculate_pearson_correlation(new_returns, port_returns) + if corr is not None and (max_correlation is None or corr > max_correlation): + max_correlation = corr + max_corr_stock = holding.stock_code - if not new_returns or not port_returns: - continue + # [R5] 선택적 fail-closed: 보유종목 데이터 미확보로 비교 제외가 발생했고 아직 임계 초과가 + # 확인되지 않았다면(= 제외된 종목이 0.7 초과였을 수 있음) 후보를 보류한다. 가드는 try 밖. + if (_holding_data_missing and not (max_correlation is not None and max_correlation > threshold) + and _switch_on("CORRELATION_FAIL_CLOSED_ON_MISSING_DATA")): + _count_corr_event("holding_data_fail_closed") + reason = f"holding_data_unavailable: {','.join(_holding_data_missing[:5])} 상관 비교 불가(fail-closed)" + logger.warning(f"[CORRELATION_FILTER] {stock_code}: {reason}") + return False, reason, max_correlation - # 피어슨 상관계수 - corr = _calculate_pearson_correlation(new_returns, port_returns) - if corr is not None and (max_correlation is None or corr > max_correlation): - max_correlation = corr - max_corr_stock = holding.stock_code + if max_correlation is None: + _count_corr_event("correlation_calc_failed") + logger.error(f"[CORRELATION] {stock_code}: 상관계수 계산 불가 — 상관 필터 우회(통과)") + if _switch_on("CORRELATION_FAIL_CLOSED_ON_CANDIDATE_DATA"): + _count_corr_event("candidate_data_fail_closed") + return False, "correlation_data_unavailable", None + return True, "correlation_calc_failed", None - if max_correlation is None: - logger.warning(f"[CORRELATION] {stock_code}: 상관계수 계산 불가") - return True, "correlation_calc_failed", None + if max_correlation > threshold: + reason = f"high_correlation_with_{max_corr_stock}: {max_correlation:.3f} (임계값: {threshold})" + logger.info(f"[CORRELATION_FILTER] {stock_code}: {reason}") + return False, reason, max_correlation - if max_correlation > threshold: - reason = f"high_correlation_with_{max_corr_stock}: {max_correlation:.3f} (임계값: {threshold})" - logger.info(f"[CORRELATION_FILTER] {stock_code}: {reason}") - return False, reason, max_correlation - - logger.info(f"[CORRELATION_PASSED] {stock_code}: 최대 상관계수 {max_correlation:.3f} (임계값 이내)") - return True, f"correlation_ok: {max_correlation:.3f}", max_correlation - - except Exception as e: - logger.error(f"[CORRELATION_ERROR] {stock_code}: {type(e).__name__}: {e}") - # 오류 시 필터 통과 (데이터 부족으로 인한 오류는 진입 거부하지 않음) - return True, f"correlation_error: {str(e)}", None + logger.info(f"[CORRELATION_PASSED] {stock_code}: 최대 상관계수 {max_correlation:.3f} (임계값 이내)") + return True, f"correlation_ok: {max_correlation:.3f}", max_correlation async def validate_correlation_filter( @@ -451,6 +498,7 @@ async def _get_daily_prices(stock_code: str, lookback_days: int = 60) -> Optiona [가격1, 가격2, ...] 시간순 정렬, 또는 None """ try: + # DB-READ-EXCEPTION: StockDailyPrice SELECT 전용(commit/add 없음), §3.2 읽기 예외 async with AsyncSessionLocal() as session: from sqlalchemy import select start_date = datetime.now().date() - timedelta(days=lookback_days) @@ -485,6 +533,129 @@ async def _get_daily_prices(stock_code: str, lookback_days: int = 60) -> Optiona return None +async def _get_daily_prices_v2(stock_code: str, lookback_days: int = 60) -> Optional[List[Tuple[object, float]]]: + """최근 N일 일봉 (날짜, 종가) 오름차순 리스트. DB 우선, KIS API 폴백. 없으면 None.""" + try: + # DB-READ-EXCEPTION: StockDailyPrice SELECT 전용(commit/add 없음), §3.2 읽기 예외 + async with AsyncSessionLocal() as session: + from sqlalchemy import select + start_date = datetime.now().date() - timedelta(days=lookback_days) + result = await session.execute( + select(StockDailyPrice.trade_date, StockDailyPrice.close_price) + .where( + StockDailyPrice.stock_code == stock_code, + StockDailyPrice.trade_date >= start_date + ) + .order_by(StockDailyPrice.trade_date.asc()) + ) + rows = [(r[0], float(r[1])) for r in result.fetchall() if r[1] is not None] + if len(rows) >= _MIN_CORR_BARS: + return rows + except Exception as e: + warn_throttled(logger, "correlation_filter:v2_db", f"[PRICES_DB] {stock_code}: {e}") + + try: + chart = await korea_investment_api.get_daily_chart(stock_code, "D", limit=lookback_days + 5) + output = chart.get("output2", []) + rows = [] + for item in reversed(output or []): + d, c = item.get("stck_bsop_date"), item.get("stck_clpr") + if d and c: + rows.append((str(d), float(c))) + if rows: + # DB 날짜(date)와 키 타입을 맞춘다 + try: + rows = [(datetime.strptime(d, "%Y%m%d").date(), c) for d, c in rows] + except ValueError: + pass + # 당일 장중 미완성 봉 제외 (KST 기준) — DB 는 T+1 적재라 당일 봉이 없다 + try: + from zoneinfo import ZoneInfo + today_kst = datetime.now(ZoneInfo("Asia/Seoul")).date() + rows = [(d, c) for d, c in rows if not (hasattr(d, "year") and d >= today_kst)] + except Exception as e: + logger.warning(f"[PRICES_API] {stock_code}: 당일봉 제외 실패: {e}") + return rows or None + except Exception as e: + warn_throttled(logger, "correlation_filter:v2_api", f"[PRICES_API] {stock_code}: {e}") + return None + + +# ─ [R5] 가격 시계열 TTL 캐시 + 일괄 선적재 ───────────────────────────────────── +_PRICE_CACHE_TTL_SEC = 120 +_price_cache: Dict[Tuple[str, int], Tuple[float, List[Tuple[object, float]]]] = {} +_price_cache_fetcher = None + + +def clear_price_cache_v3() -> None: + """가격 시계열 캐시 비우기 (테스트·수동 갱신용).""" + _price_cache.clear() + + +def _price_cache_get(stock_code: str, lookback_days: int): + # 조회 함수가 교체(패치)되면 캐시를 버린다 — 다른 소스의 값이 섞이는 것 방지 + global _price_cache_fetcher + if _price_cache_fetcher is not _get_daily_prices_v2: + _price_cache.clear() + _price_cache_fetcher = _get_daily_prices_v2 + hit = _price_cache.get((stock_code, lookback_days)) + if hit and (datetime.now().timestamp() - hit[0]) <= _PRICE_CACHE_TTL_SEC: + return hit[1] + return None + + +def _price_cache_put(stock_code: str, lookback_days: int, rows) -> None: + if rows and len(rows) >= _MIN_CORR_BARS: # 실패/부족 결과는 캐시하지 않는다 + if len(_price_cache) > 2000: + _price_cache.clear() + _price_cache[(stock_code, lookback_days)] = (datetime.now().timestamp(), rows) + + +async def _get_daily_prices_cached_v3(stock_code: str, lookback_days: int = 60): + """TTL 캐시 경유 시계열 조회. 미스면 _get_daily_prices_v2(DB→KIS 폴백).""" + cached = _price_cache_get(stock_code, lookback_days) + if cached is not None: + return cached + rows = await _get_daily_prices_v2(stock_code, lookback_days) + _price_cache_put(stock_code, lookback_days, rows) + return rows + + +async def _prefetch_daily_prices_v3(stock_codes: List[str], lookback_days: int = 60) -> None: + """캐시에 없는 종목들의 DB 시계열을 한 세션 IN 쿼리로 선적재한다. + + 부족(<_MIN_CORR_BARS)한 종목은 캐시하지 않아 이후 _get_daily_prices_v2 의 KIS 폴백이 처리한다. + 조회 함수가 패치된 경우(테스트)에는 DB 에 접근하지 않는다. 실패는 warning 후 개별 조회로 위임. + """ + if _get_daily_prices_v2 is not _ORIG_GET_DAILY_PRICES_V2: + return + missing = [c for c in dict.fromkeys(stock_codes) if _price_cache_get(c, lookback_days) is None] + if not missing: + return + try: + from sqlalchemy import select + start_date = datetime.now().date() - timedelta(days=lookback_days) + # DB-READ-EXCEPTION: StockDailyPrice SELECT 전용(commit/add 없음), 사이클당 1세션 일괄 조회 + async with AsyncSessionLocal() as session: + result = await session.execute( + select(StockDailyPrice.stock_code, StockDailyPrice.trade_date, StockDailyPrice.close_price) + .where(StockDailyPrice.stock_code.in_(missing), + StockDailyPrice.trade_date >= start_date) + .order_by(StockDailyPrice.stock_code, StockDailyPrice.trade_date.asc()) + ) + by_code: Dict[str, List[Tuple[object, float]]] = {} + for code, d, c in result.fetchall(): + if c is not None: + by_code.setdefault(code, []).append((d, float(c))) + for code, rows in by_code.items(): + _price_cache_put(code, lookback_days, rows) + except Exception as e: + warn_throttled(logger, "correlation_filter:prefetch", f"[PRICES_DB] 일괄 선적재 실패: {e}") + + +_ORIG_GET_DAILY_PRICES_V2 = _get_daily_prices_v2 + + def _calculate_returns(prices: List[float]) -> List[float]: """ 가격 시계열 → 일간 수익률 변환. diff --git a/backend/app/services/day_trading_strategy.py b/backend/app/services/day_trading_strategy.py index d0dc845..ffb56df 100644 --- a/backend/app/services/day_trading_strategy.py +++ b/backend/app/services/day_trading_strategy.py @@ -789,7 +789,7 @@ def check_day_trade_exit( 1. EOD 강제 청산 (15:20 이후) 2. 손절 (현재가 ≤ stop_loss) - 3. 시간 손절 — 진입 후 30분간 +0.3% 미달 시 청산 + 3. 시간 손절 — 진입 후 DAY_TRADE_TIME_STOP_MINUTES 경과 이후 수익률 < DAY_TRADE_TIME_STOP_MIN_GAIN 이면 청산 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2) 5. 트레일링 스탑 (1.5% 수익 후 ATR 트레일) 6. 5분봉 RSI ≥ 70 (과매수 청산) @@ -819,7 +819,7 @@ def check_day_trade_exit( if gain_pct < settings.DAY_TRADE_TIME_STOP_MIN_GAIN: # 최소 수익 미달 (수수료 커버 못함) return DayTradeExit( action="SELL_ALL", - reason=f"{TIME_STOP_REASON_PREFIX}: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%", + reason=f"{TIME_STOP_REASON_PREFIX}: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.2f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.2f}%", ) # 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도) diff --git a/backend/app/services/daytrading/entry_service.py b/backend/app/services/daytrading/entry_service.py index 81c7533..83e9c1e 100644 --- a/backend/app/services/daytrading/entry_service.py +++ b/backend/app/services/daytrading/entry_service.py @@ -152,6 +152,354 @@ def _reset_orb_finalized_if_new_day() -> None: _pending_entries: dict[str, float] = {} +# ── [하드닝 R2 2026-10-09] 타전략 보유 종목 DT 진입 차단 ───────────────────────── +# 배경: DT 진입은 DT 인메모리 포지션·closed_today 만 보고, 같은 종목의 스윙/외부/동기화 +# 보유를 확인하지 않았다. Portfolio 는 stock_code UNIQUE 라 수량이 합산되고(§3.5), +# 청산 쪽 차감·Portfolio 태그 소유권·고아 판정이 모두 모호해진다(실측: 105560 synced 3주 +# 보유 중 DT 가 10/01·10/06 두 차례 매수 → Portfolio 행이 'daytrading' 태그로 바뀜). +# 스윙 쪽 누적 상한(trade_executor._apply_position_cap_room_v2)은 DT 수량을 세지만 +# DT 쪽에는 역방향 검사가 없어 한 종목이 4%(스윙)+6%(DT) 까지 쌓일 수 있었다. +# +# 정책(더 안전한 쪽): **비-DT 보유가 있는 종목에는 DT 가 신규 진입하지 않는다.** +# 두 소스를 쓴다 — 둘 다 실시간 루프에 새 DB I/O 를 만들지 않는다(§3.1). +# ① DB 스냅샷: populate_daytrading_watchlist 의 읽기 전용 세션(§3.2 ③)에서 +# active 비-DT ActiveTrade(not_dt_strategy_clause) + 비-DT Portfolio 행을 SELECT. +# 실패하면 warning 후 빈 집합(② 가 주 가드이므로 진입 전체를 막지 않는다). +# ② KIS 잔고 스냅샷: scan_and_enter 가 사이클마다 이미 호출하는 get_balance() 의 +# output1(추가 호출 없음). DT 인메모리 포지션이 아닌 보유 종목 = 타전략/고아 보유. +# 수동 watchlist(set_watchlist) 도 이 가드를 탄다. +# ⚠️ get_balance 는 첫 페이지만 조회한다(§8.5-37) — 보유가 많으면 ① 이 보완한다. +_non_dt_held_db: set[str] = set() +# 마지막 사이클의 KIS 보유 {code: {"qty": int, "name": str, "eval": float}} +_kis_holdings_snapshot: dict[str, dict] = {} +# output1 을 실제로 해석했는지(응답 이상이면 False — 레버리지 한도 검증 불가로 본다) +_kis_holdings_verified: bool = False + + +def _parse_kis_holdings_v2(balance) -> dict[str, dict] | None: + """잔고 응답 output1 → {code: {"qty","name","eval"}}. output1 이 없으면 None(검증 불가).""" + if not isinstance(balance, dict): + return None + rows = balance.get("output1") + if rows is None or not isinstance(rows, list): + return None + out: dict[str, dict] = {} + for h in rows: + if not isinstance(h, dict): + continue + code = str(h.get("pdno", "") or "").strip() + if not code: + continue + try: + qty = int(float(str(h.get("hldg_qty", 0) or 0).replace(",", ""))) + except (TypeError, ValueError): + qty = 0 + if qty <= 0: + continue + try: + ev = float(str(h.get("evlu_amt", 0) or 0).replace(",", "")) + except (TypeError, ValueError): + ev = 0.0 + if ev <= 0: + try: + ev = qty * float(str(h.get("prpr", 0) or 0).replace(",", "")) + except (TypeError, ValueError): + ev = 0.0 + out[code] = {"qty": qty, "name": str(h.get("prdt_name", "") or ""), "eval": ev} + return out + + +def _non_dt_holding_reason_v2(stock_code: str) -> str | None: + """타전략(스윙/외부/동기화) 또는 DT 인메모리에 없는 KIS 보유가 있으면 차단 사유.""" + if stock_code in _non_dt_held_db: + return "타전략 보유(DB: 비-DT ActiveTrade/Portfolio)" + if ( + stock_code in _kis_holdings_snapshot + and stock_code not in day_trading_state.positions + and stock_code not in _pending_entries + ): + _q = _kis_holdings_snapshot[stock_code].get("qty", 0) + return f"KIS 잔고 {_q}주 보유(DT 인메모리 포지션 아님 — 타전략/고아)" + return None + + +async def _refresh_non_dt_held_db_v2() -> None: + """비-DT 보유 종목 코드 스냅샷 갱신 — populate_daytrading_watchlist 전용(읽기 전용). + + 실패하면 warning 후 **이전 스냅샷을 유지**한다(비우면 가드가 조용히 풀린다). + """ + global _non_dt_held_db + from app.core.database import AsyncSessionLocal + from app.models.models import ActiveTrade, Portfolio + from app.services.db_worker import not_dt_strategy_clause + from sqlalchemy import select + + try: + # DB-READ-EXCEPTION: §3.2 ③ populate_daytrading_watchlist 경로 — SELECT 전용, commit 없음 + async with AsyncSessionLocal() as session: + held_rows = (await session.execute( + select(ActiveTrade.stock_code).where( + ActiveTrade.status == "active", + ActiveTrade.quantity > 0, + not_dt_strategy_clause(ActiveTrade.strategy_name), + ).union( + select(Portfolio.stock_code).where( + Portfolio.quantity > 0, + not_dt_strategy_clause(Portfolio.strategy_type), + ) + ) + )).all() + except Exception as e: + logger.warning( + f"[DayEntry] 타전략 보유 스냅샷 조회 실패(읽기 전용) — 이전 스냅샷 " + f"{len(_non_dt_held_db)}종목 유지, KIS 잔고 가드로 보완: {e}" + ) + return + codes: set[str] = set() + for r in held_rows: + try: + c = r[0] + except (TypeError, IndexError, KeyError): + c = getattr(r, "stock_code", None) + if c: + codes.add(str(c)) + _non_dt_held_db = codes + + +def _price_filter_block_v2(stock_name: str, price: float) -> str | None: + """§5.1 가격 필터(국내 MIN_PRICE_KRW '초과'만 매수). ETF/ETN/리츠 예외는 trade_executor 와 동일 정의.""" + if price > settings.MIN_PRICE_KRW: + return None + from app.services.trade_executor import _is_price_filter_exempt_name_v2 + if _is_price_filter_exempt_name_v2(stock_name): + return None + return f"가격 필터: 현재가 {price:,.0f}원 ≤ {settings.MIN_PRICE_KRW:,}원" + + +def _leverage_block_v2(stock_code: str, stock_name: str, budget: float, total_assets: float) -> str | None: + """§5.1 레버리지/인버스 ETF 20%(LEVERAGE_ETF_MAX_RATIO) — 이번 주문 포함. + + 보유 평가액은 사이클 KIS 잔고 스냅샷 + 스냅샷 이후 들어온 DT 포지션(진입가×수량). + 스냅샷을 해석하지 못했으면 검증 불가 → 레버리지 종목만 차단(fail-closed, + trade_executor 7.7 단계와 같은 정책). + """ + from app.services.trade_helpers import get_leverage_check_keywords, is_leverage_etf + kw = get_leverage_check_keywords() + if not is_leverage_etf(stock_name, kw): + return None + if not _kis_holdings_verified or total_assets <= 0: + return ( + f"레버리지 한도 {settings.LEVERAGE_ETF_MAX_RATIO:.0%} 검증 불가" + f"(잔고 보유목록 미확인) — fail-closed" + ) + lev_total = sum( + float(h.get("eval", 0) or 0) + for h in _kis_holdings_snapshot.values() + if is_leverage_etf(h.get("name", ""), kw) + ) + for code, pos in day_trading_state.positions.items(): + if code in _kis_holdings_snapshot: + continue + if is_leverage_etf(str(pos.get("stock_name", "") or ""), kw): + lev_total += float(pos.get("entry_price", 0) or 0) * float(pos.get("quantity", 0) or 0) + ratio_after = (lev_total + max(0.0, budget)) / total_assets + if ratio_after > settings.LEVERAGE_ETF_MAX_RATIO: + return ( + f"레버리지 ETF 비중 {ratio_after:.1%}(보유 {lev_total:,.0f} + 신규 {budget:,.0f}) " + f"> {settings.LEVERAGE_ETF_MAX_RATIO:.0%} 한도" + ) + return None + + +# ── [하드닝 루프 R1 2026-10-09] §5.1 섹터캡·최대 보유 종목 수 (플래그, 기본 OFF) ───────── +# 배경: 스윙/수동 경로는 섹터당 3종목(correlation_filter)·MAX_HOLDINGS_COUNT 를 적용하지만 +# DT 진입은 어느 쪽도 보지 않아 같은 섹터 5종목(5×6%=30%) 쏠림, 스윙 20 + DT 5 = 25종목이 +# 가능했다. §7 에는 DT 섹터 규칙이 없고 거래 빈도에 직접 영향(백테스트 미검증)이 있으므로 +# **설정 플래그로 출시하고 기본은 OFF**(현행 동작 유지)다. 플래그는 config.py 에 아직 없을 수 +# 있어 getattr 폴백(False)으로 읽는다. +# - DAY_TRADE_SECTOR_CAP_ENABLED : 섹터당 상한(correlation_filter 기본값과 동일 출처) +# - DAY_TRADE_HOLDINGS_CAP_ENABLED : 전체 보유 종목 수 상한(settings.MAX_HOLDINGS_COUNT) +# 실시간 계층이므로 DB·네트워크 I/O 없이 이미 있는 스냅샷만 읽는다(§3.1): +# KIS 잔고 스냅샷(사이클별) ∪ 비-DT DB 스냅샷(populate 시 읽기 전용) ∪ DT 인메모리 ∪ 주문 진행. +# 섹터는 sector_mapper.get_sector(큐레이션 표 → 파일 캐시, 동기). correlation_filter. +# get_stock_sector 는 캐시 미스 시 DB 세션을 열므로 여기서 쓰지 않는다. +# 미매핑 처리는 correlation_filter 와 같은 SECTOR_CAP_UNMAPPED_ENABLED 규약을 따른다. +# 플래그가 켜졌는데 KIS 보유목록을 해석하지 못했으면 검증 불가 → 차단(fail-closed, +# 레버리지 가드와 같은 정책). ⚠️ get_balance 는 첫 페이지만 본다(§8.5-37) — DB 스냅샷이 보완. +def _dt_sector_cap_limit_v2() -> int: + """섹터당 상한 — correlation_filter.check_sector_concentration 기본값을 단일 출처로 쓴다.""" + import inspect + from app.services.correlation_filter import check_sector_concentration + return int(inspect.signature(check_sector_concentration).parameters["max_per_sector"].default) + + +def _dt_cap_flags_v2() -> tuple[bool, bool]: + """(섹터캡, 보유수 상한) DT 적용 플래그. config.py 에 필드가 없으면 False(현행 동작).""" + return ( + bool(getattr(settings, "DAY_TRADE_SECTOR_CAP_ENABLED", False)), + bool(getattr(settings, "DAY_TRADE_HOLDINGS_CAP_ENABLED", False)), + ) + + +def _dt_current_holding_codes_v2(exclude: str) -> set[str]: + codes: set[str] = set() + codes.update(str(c) for c in _kis_holdings_snapshot.keys()) + codes.update(str(c) for c in _non_dt_held_db) + codes.update(str(c) for c in day_trading_state.positions.keys()) + codes.update(str(c) for c in _pending_entries.keys()) + codes.discard(str(exclude)) + return codes + + +def _portfolio_cap_block_v2(stock_code: str) -> str | None: + """§5.1 섹터캡·최대 보유 종목 수(DT 적용은 플래그). 차단 사유 또는 None. + + 리스크 가드이므로 호출부에서 try 로 감싸지 않는다(§4). 섹터 조회 예외만 내부에서 + 흡수하되 플래그 ON 상태에서는 차단(fail-closed)한다. + """ + sector_on, holdings_on = _dt_cap_flags_v2() + if not sector_on and not holdings_on: + return None + if not _kis_holdings_verified: + return "섹터/보유수 상한 검증 불가(잔고 보유목록 미확인) — fail-closed" + + held = _dt_current_holding_codes_v2(exclude=stock_code) + + if holdings_on and len(held) >= settings.MAX_HOLDINGS_COUNT: + return f"최대 보유 종목 수 {len(held)} ≥ {settings.MAX_HOLDINGS_COUNT}" + + if sector_on: + from app.services import sector_mapper + cap_unmapped = bool(getattr(settings, "SECTOR_CAP_UNMAPPED_ENABLED", False)) + max_per_sector = _dt_sector_cap_limit_v2() + try: + sector = sector_mapper.get_sector(stock_code) + held_sectors = [sector_mapper.get_sector(c) for c in held] + except Exception as e: + logger.warning(f"[DayEntry] 섹터 조회 실패 ({stock_code}) — fail-closed: {e}") + return f"섹터 조회 실패 — fail-closed ({e})" + if not sector: + if not cap_unmapped: + return None + sector = "UNKNOWN" + if cap_unmapped: + held_sectors = [s or "UNKNOWN" for s in held_sectors] + count = sum(1 for s in held_sectors if s == sector) + if count >= max_per_sector: + return f"섹터 {sector} 이미 {count}종목 보유(최대 {max_per_sector}종목)" + return None + + +# ── [하드닝 R2 2026-10-09] ORB 지연 보충 ────────────────────────────────────────── +# collect_opening_ranges 는 09:00~09:29 분기에서만, backfill_opening_ranges 는 재기동 시 +# orb_cache 가 통째로 비었을 때만 돈다. 그래서 (a) 30분 갱신으로 새로 들어온 종목, +# (b) 09:00~09:29 에 봉 부족(VI 등)으로 수집에 실패한 종목은 하루 종일 ORB 가 없었다. +# 진입 스캔이 이미 선조회한 분봉(당일 09:00 부터 포함 — VWAP 세션 앵커와 같은 시계열)으로 +# 09:30 이후 ORB 를 계산한다 — **추가 KIS 호출 없음**. 계산식은 backfill 과 동일 +# (_opening_range_v2: FULL_RANGE=False 면 창 안 첫 N봉, True 면 09:00~09:29 전체). +_ORB_WINDOW_END = dtime(9, 30) +# 당일 분봉이 개장 직후부터 들어 있어야 레인지가 잘리지 않는다(페이지 누락 방어). +_ORB_BACKFILL_FIRST_BAR_MAX = "090459" +_ORB_BACKFILL_WINDOW_CLOSED_MIN = "093000" + + +# [하드닝 R2-2 2026-10-09] ORB 레인지 정의 통일. +# FULL_RANGE=False(기본, §8.2 사용자 결정 '첫 5분 레인지')의 평상시 실측 동작은 `has_orb()` +# 단락 때문에 "09:00 이후 첫 5봉"(calculate_opening_range 의 최소 봉 수)으로 하루가 고정되는 +# 것이다. 그런데 09:30 이후 소급(backfill_opening_ranges → collect_opening_ranges)과 지연 +# 보충(_backfill_orb_from_cache_v2)은 당일 분봉 전체를 넘겨 **09:00~09:29 30봉** 레인지를 +# 만들었다 → 같은 날 같은 종목의 ORB 고저·분당평균거래량·폭 필터가 재기동 여부로 달라졌다. +# 이제 FULL_RANGE=False 면 모든 경로가 창(09:00~09:29) 안의 **시각순 첫 N봉**만 쓴다. +# 평상시 첫 수집은 N번째 봉이 **완성된 뒤**(R6 2026-10-10, `_orb_bar_closed_v2`) 성공하므로 +# 소급·지연 보충과 값이 같다(그 전에는 형성 중 봉의 부분값으로 고정돼 달랐다). +# FULL_RANGE=True 면 기존대로 창 전체. +_ORB_FIRST_BARS = 5 # calculate_opening_range 의 최소 봉 수와 동일(첫 성공 시점의 봉 수) +_ORB_WINDOW_START_HHMMSS = "090000" +_ORB_WINDOW_END_HHMMSS = "092959" + + +def _opening_range_v2(candles: list, full_range: bool): + """FULL_RANGE 설정에 맞는 ORB 정의로 계산한다(순수 계산).""" + if full_range: + return calculate_opening_range(candles) + in_window = sorted( + (c for c in (candles or []) + if _ORB_WINDOW_START_HHMMSS <= str(getattr(c, "time", "") or "") <= _ORB_WINDOW_END_HHMMSS), + key=lambda c: str(getattr(c, "time", "") or ""), + ) + first = in_window[:_ORB_FIRST_BARS] + # [하드닝 R6 2026-10-10] 마지막으로 쓰는 봉이 완성됐을 때만 확정한다. + # 정기 수집은 30초 주기 + has_orb() 단락이라, 5번째 봉(대개 09:04)이 막 생긴 사이클 + # (예: 09:04:05)에 그 **형성 중 봉**의 부분 고가·저가·거래량으로 하루 ORB 가 고정됐다. + # 재기동 소급·지연 보충은 완성된 봉을 써서 같은 날 같은 종목의 ORB 가 달라졌다. + # 레인지 정의(첫 N봉)는 바꾸지 않고 확정 시점만 '마지막 봉 시각 + 60초 ≤ 현재'로 늦춘다. + if len(first) >= _ORB_FIRST_BARS and not _orb_bar_closed_v2(first[-1]): + return None + return calculate_opening_range(first) + + +_ORB_BAR_SECONDS = 60 # 1분봉 길이 + + +def _orb_bar_closed_v2(candle) -> bool: + """1분봉이 완성됐는지(봉 시작 + 60초 ≤ 현재 KST). 판정 불가면 False(미확정 → 다음 사이클 재시도).""" + now = datetime.now(_KST) + bar_date = str(getattr(candle, "date", "") or "") + today = now.strftime("%Y%m%d") + if bar_date and bar_date < today: + return True # 과거 날짜 봉은 완성됨 + t = str(getattr(candle, "time", "") or "") + if len(t) != 6 or not t.isdigit(): + logger.warning(f"[DayEntry] ORB 봉 시각 해석 불가({t!r}) — 확정 보류") + return False + bar_start = int(t[0:2]) * 3600 + int(t[2:4]) * 60 + int(t[4:6]) + now_sec = now.hour * 3600 + now.minute * 60 + now.second + if bar_date and bar_date > today: + return False + return bar_start + _ORB_BAR_SECONDS <= now_sec + + +def _orb_from_candles_v2(candles: list): + """당일 봉으로 ORB 계산. 레인지 창이 덮이지 않았으면 None.""" + today = filter_today_candles(candles or []) + if not today: + return None + times = sorted(str(getattr(c, "time", "") or "") for c in today) + times = [t for t in times if t] + if not times or times[0] > _ORB_BACKFILL_FIRST_BAR_MAX or times[-1] < _ORB_BACKFILL_WINDOW_CLOSED_MIN: + return None + return _opening_range_v2(today, bool(settings.DAY_TRADE_ORB_FULL_RANGE)) + + +def _backfill_orb_from_cache_v2(codes: list[str]) -> int: + """선조회 캐시로 ORB 없는 종목의 ORB 를 채운다. 채운 종목 수 반환(순수 계산 + 인메모리).""" + if datetime.now(_KST).time() < _ORB_WINDOW_END: + return 0 + _full_range = bool(settings.DAY_TRADE_ORB_FULL_RANGE) + if _full_range: + _reset_orb_finalized_if_new_day() + filled = 0 + for code in codes: + if day_trading_state.has_orb(code): + continue + cached = _candle_cache.get(code) + if not cached: + continue + orb = _orb_from_candles_v2(cached[1]) + if not orb: + continue + day_trading_state.set_orb(code, orb) + if _full_range: + _orb_finalized.add(code) + filled += 1 + _w = (orb.high - orb.low) / orb.low * 100 if orb.low > 0 else 0.0 + logger.info( + f"[DayEntry] ORB 지연 보충(09:30 이후, 선조회 분봉): {code} " + f"고={orb.high:,.0f} 저={orb.low:,.0f} 폭={_w:.2f}%" + ) + return filled + + # ── RSI·갭 진단 로깅 (2026-09-30) ───────────────────────────────────────────── # 배경: 장중 5분봉 RSI 는 워밍업 시계열(전일 봉 포함)을 쓰므로 오버나이트 갭이 종가 # 차분 한 개로 RSI 에 들어간다(실측: 3% 갭하락 → 워밍업 RSI 52.7 → 30.3). 사용자 @@ -249,7 +597,23 @@ def _clamp_day_trade_budget( """ per_position_cap = total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO remaining_dt_cap = total_assets * settings.DAY_TRADE_MAX_RATIO_DT - current_day_trade_amount - return max(0.0, min(budget, per_position_cap, remaining_dt_cap, available_cash)) + # [하드닝 R5 2026-10-09] §5.1 전 전략 합산 현금 사용 상한(MAX_TOTAL_CAPITAL_USAGE, 95%)을 + # **주문 후** 기준으로 지킨다. 예전에는 사이클 가드가 주문 전 사용률(> 95%)만 보고 이 + # 함수는 현금 전액까지 허용해, 스윙 70% + DT 30% 로 현금 유보 5% 가 0 이 될 수 있었다. + # 투자액 = 총자산 − 가용현금 이므로 "주문 후 사용률 ≤ 상한" ⇔ + # budget ≤ 가용현금 − 총자산 × (1 − 상한). 리스크 클램프라 try 밖(§4). + cash_room = _total_usage_cash_room_v2(total_assets, available_cash) + return max(0.0, min(budget, per_position_cap, remaining_dt_cap, available_cash, cash_room)) + + +def _total_usage_cash_room_v2(total_assets: float, available_cash: float) -> float: + """전 전략 합산 사용 상한(MAX_TOTAL_CAPITAL_USAGE)을 넘지 않고 쓸 수 있는 현금(원, 0 이상). + + 유보 비율은 ``1 − MAX_TOTAL_CAPITAL_USAGE`` 를 [0, 1] 로 자른다(상한이 1 초과로 + 잘못 설정돼도 유보가 음수가 되어 현금 이상을 허용하지 않게). + """ + reserve_ratio = min(1.0, max(0.0, 1.0 - float(settings.MAX_TOTAL_CAPITAL_USAGE))) + return max(0.0, float(available_cash) - float(total_assets) * reserve_ratio) def set_watchlist(stocks: list[dict]) -> None: @@ -288,7 +652,8 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int | No - 오늘 날짜 분석 결과만 사용 - score >= min_score (기본 1점 이상) - RSI 값이 존재 (기술적 분석 완료 종목만) - - 현재가 > 5,000원 (CLAUDE.md 5.4 가격 필터) + - 현재가 > settings.MIN_PRICE_KRW (CLAUDE.md §5.1 가격 필터) + - 타전략(스윙/외부/동기화) 보유 종목 제외 (하드닝 R2) Args: min_score: 최소 점수 기준 (기본 1) @@ -316,6 +681,12 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int | No max_stocks = settings.DAY_TRADE_WATCHLIST_SIZE today = datetime.now(_KST).date() + + # [하드닝 R2 2026-10-09] 타전략 보유 종목 스냅샷(읽기 전용, §3.2 ③ 같은 함수 안). + # 별도 세션 — 실패해도 아래 분석 조회와 무관하게 진행한다(KIS 잔고 가드가 주 가드). + await _refresh_non_dt_held_db_v2() + _held = set(_non_dt_held_db) + # [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유] # 이 세션은 SELECT 2건만 수행하며 commit()/flush()/add() 를 호출하지 않는다. # 세션 수명은 아래 블록으로 최소화하고, 실패 시 warning 으로 운영에 노출한다. @@ -337,16 +708,27 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int | No logger.info("[DayEntry] stock_analysis 데이터 없음") return 0 + # 가격 필터는 settings(§5.1 '초과') — 하드코딩 5000 제거. 타전략 보유 종목을 + # 걸러낸 뒤에도 상한을 채울 수 있게 그만큼 더 읽는다. result = await session.execute( select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score) .where( StockAnalysis.analysis_date == latest_date, StockAnalysis.score >= min_score, StockAnalysis.rsi.isnot(None), - StockAnalysis.current_price > 5000, + StockAnalysis.current_price > settings.MIN_PRICE_KRW, ) - .order_by(StockAnalysis.score.desc()) - .limit(max_stocks) + # [하드닝 R5 2026-10-09] 결정적 정렬 — 규칙 점수는 정수(실측 score=3 동점군 + # 59~74종목)라 컷오프가 동점군 한가운데 걸리고, PostgreSQL 은 동점 행 순서를 + # 보장하지 않아 30분 갱신마다 구성원이 임의로 바뀔 수 있었다. 동점은 일간 + # 거래대금(volume×current_price, NULL 마지막) → stock_code 오름차순으로 끊는다. + # 선정 기준 자체(스윙 점수)를 DT 용 지표로 바꾸는 것은 백테스트 후 별도 작업. + .order_by( + StockAnalysis.score.desc(), + (StockAnalysis.volume * StockAnalysis.current_price).desc().nulls_last(), + StockAnalysis.stock_code.asc(), + ) + .limit(max_stocks + len(_held)) ) rows = result.all() except Exception as e: @@ -361,28 +743,47 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int | No return 0 stocks = [{"code": r.stock_code, "name": r.stock_name} for r in rows] - - # [F8 2026-09-28] ORB 를 이미 수집한 종목은 교체에서 살린다. - # set_watchlist() 는 목록을 **통째로 재대입**하고, 스케줄러의 30분 갱신은 - # _last_wl_sync=0.0 초기값 때문에 09:30 첫 사이클에서 즉시 발동한다. - # 09:00~09:29 에 ORB 를 수집한 종목이 여기서 빠지면(당일 배치가 다른 종목을 - # 상위로 올리면 실제로 빠진다) 스캔 루프가 _watchlist 만 순회하므로 - # 그 ORB 는 버려지고 당일 ORB 진입이 0건이 된다. - _kept = [ - {"code": c, "name": _watchlist.get(c, c)} - for c in _watchlist - if day_trading_state.has_orb(c) - ] - if _kept: - _kept_codes = {x["code"] for x in _kept} - merged = _kept + [x for x in stocks if x["code"] not in _kept_codes] - dropped = len(merged) - max_stocks - merged = merged[:max_stocks] + _excluded_held = [x["code"] for x in stocks if x["code"] in _held] + if _excluded_held: logger.info( - f"[DayEntry] watchlist 갱신 시 ORB 보유 {len(_kept)}종목 유지" - + (f" (신규 후보 {dropped}종목은 상한 {max_stocks}개로 탈락)" if dropped > 0 else "") + f"[DayEntry] watchlist 에서 타전략 보유 {len(_excluded_held)}종목 제외 " + f"(DT 중첩 진입 방지): {_excluded_held[:10]}" ) - stocks = merged + stocks = [x for x in stocks if x["code"] not in _held][:max_stocks] + + # [하드닝 R2 2026-10-09] 갱신은 **점수 순위**로 교체한다(§7 "30분마다 갱신"). + # 예전(F8)에는 ORB 보유 종목을 앞에 고정하고 상한으로 잘라, 09:00 목록 30종목이 모두 + # ORB 를 가지면 갱신이 no-op 이 되어 장중 stock_analysis 갱신이 반영되지 않았다. + # 새로 들어온 종목의 ORB 는 진입 스캔이 선조회 분봉으로 보충한다 + # (`_backfill_orb_from_cache_v2`, 추가 KIS 호출 없음) — ORB 를 잃지 않으려고 목록을 + # 동결할 이유가 없어졌다. 단 점수 후보가 상한에 못 미치면 기존 ORB 보유 종목으로 + # 빈자리를 채운다(F8 의 의도 — 수집한 ORB 를 버리지 않기 — 는 여유분에서 유지). + if len(stocks) < max_stocks: + _codes = {x["code"] for x in stocks} + _pad = [ + {"code": c, "name": _watchlist.get(c, c)} + for c in _watchlist + if day_trading_state.has_orb(c) and c not in _codes and c not in _held + ][: max_stocks - len(stocks)] + if _pad: + logger.info( + f"[DayEntry] watchlist 갱신: 점수 후보 {len(stocks)}개 < 상한 {max_stocks} — " + f"기존 ORB 보유 {len(_pad)}종목으로 보충" + ) + stocks = stocks + _pad + + _prev = set(_watchlist) + _new = {x["code"] for x in stocks} + if _prev: + _added = sorted(_new - _prev) + _removed = sorted(_prev - _new) + if _added or _removed: + logger.info( + f"[DayEntry] watchlist 갱신 변경: +{len(_added)} {_added[:10]} / " + f"-{len(_removed)} {_removed[:10]} (ORB 없는 신규 종목은 09:30 이후 스캔 시 보충)" + ) + else: + logger.info(f"[DayEntry] watchlist 갱신: 변경 없음 ({len(_new)}종목)") set_watchlist(stocks) global _watchlist_auto @@ -563,8 +964,8 @@ async def collect_opening_ranges() -> None: # [H1 2026-10-02] ⚠️ 현행(기본, DAY_TRADE_ORB_FULL_RANGE=False) 실제 동작: # `has_orb()` 단락 때문에 **첫 수집 성공 시점의 레인지로 하루가 고정**된다. - # calculate_opening_range 는 1분봉 5개면 값을 내므로 09:00 첫 사이클 직후(대개 09:05 - # 전후)의 5분 레인지가 ORB 가 된다 — 문서(§7)의 09:00~09:29 레인지가 아니다. + # _opening_range_v2 는 첫 5봉이 모두 완성된 뒤(R6: 5번째 봉 시각+60초 ≤ 현재, 대개 09:05:00 + # 이후 첫 사이클)의 5분 레인지를 ORB 로 낸다 — 문서(§7)의 09:00~09:29 레인지가 아니다. # 돌파 신호가 더 많이 나는 쪽이라 사용자 결정으로 현행을 유지한다. # ON: 09:29 이전에는 매 수집마다 재계산(확정 아님), 09:29 이후 첫 계산을 확정본으로 두고 # 그때부터만 Redis 에 저장한다(09:29 이전 저장분은 재기동 시 확정본으로 쓰지 않는다). @@ -587,7 +988,8 @@ async def collect_opening_ranges() -> None: raw = await korea_investment_api.get_minute_chart_full_day(stock_code) candles = filter_today_candles(parse_minute_candles(raw)) if candles: - orb = calculate_opening_range(candles) + # [R2-2] FULL_RANGE=False 면 첫 N봉 — 09:30 이후 소급도 평상시와 같은 정의 + orb = _opening_range_v2(candles, _full_range) if orb: day_trading_state.set_orb(stock_code, orb) if _full_range and _finalize_now: @@ -636,7 +1038,8 @@ async def backfill_opening_ranges() -> int: 있으면 복원되지만, 캐시가 없거나 09:30 전에 한 번도 수집되지 않았으면 불가). ``get_minute_chart_full_day`` 는 당일 전체 분봉을 주므로 09:30 이후에도 - 09:00~09:29 레인지를 정확히 소급 계산할 수 있다. 전일 캔들 혼입은 + ORB 레인지를 정확히 소급 계산할 수 있다(FULL_RANGE=False 면 평상시와 같은 첫 N봉, + `_opening_range_v2` — 2026-10-09 R2-2 정의 통일). 전일 캔들 혼입은 ``filter_today_candles()`` 가 차단한다. 진입 게이트는 건드리지 않는다 — 수집만 수행한다. @@ -798,8 +1201,22 @@ async def scan_and_enter() -> list[dict]: return entries # 포트폴리오 총자산 조회 (캐시 활용) + # [하드닝 R2] 이번 사이클의 KIS 보유 스냅샷 — 잔고 응답을 해석하지 못하면 비운다. + global _kis_holdings_snapshot, _kis_holdings_verified + _kis_holdings_snapshot = {} + _kis_holdings_verified = False try: balance = await korea_investment_api.get_balance() + _parsed_holdings = _parse_kis_holdings_v2(balance) + if _parsed_holdings is None: + warn_throttled( + logger, "entry_service:holdings_unverified", + "[DayEntry] ⚠️ 잔고 응답에 보유목록(output1) 없음 — 타전략 보유 가드는 DB 스냅샷만, " + "레버리지 ETF 진입은 차단(fail-closed)", + ) + else: + _kis_holdings_snapshot = _parsed_holdings + _kis_holdings_verified = True # output2가 빈 리스트([])로 오는 경우까지 방어 (get의 default는 키가 없을 때만 적용됨) summary = (balance.get("output2") or [{}])[0] total_assets = float(summary.get("tot_evlu_amt", 0) or 0) @@ -840,6 +1257,7 @@ async def scan_and_enter() -> list[dict]: scanned = 0 skipped_held = 0 skipped_closed = 0 + skipped_other_strategy = 0 no_signal = 0 truncated = 0 @@ -853,6 +1271,11 @@ async def scan_and_enter() -> list[dict]: if day_trading_state.is_closed_today(stock_code): skipped_closed += 1 continue + # [하드닝 R2] 타전략(스윙/외부/동기화) 보유 종목은 DT 신규 진입 제외(§3.5 소유권 모호 방지) + _held_reason = _non_dt_holding_reason_v2(stock_code) + if _held_reason: + skipped_other_strategy += 1 + continue candidates.append((stock_code, stock_name)) # 라운드로빈: 시간 예산에 걸려 잘려도 다음 사이클은 다른 종목부터 본다 @@ -870,6 +1293,15 @@ async def scan_and_enter() -> list[dict]: prefetched = await _prefetch_minute_charts([c for c, _ in candidates]) _prefetch_sec = time.monotonic() - _scan_started + # [하드닝 R2] 09:30 이후 ORB 없는 감시종목(30분 갱신 신규·09시대 수집 실패)의 ORB 를 + # 선조회 분봉으로 보충한다 — 추가 KIS 호출 없음. Redis 저장 실패는 진입과 무관하다. + _orb_filled = _backfill_orb_from_cache_v2([c for c, _ in candidates]) + if _orb_filled: + try: + await day_trading_state.save_orb_cache() + except Exception as e: + logger.warning(f"[DayEntry] ORB 지연 보충분 Redis 저장 실패(인메모리는 유지): {e}") + for idx, (stock_code, stock_name) in enumerate(candidates): if day_trading_state.position_count() >= MAX_POSITIONS: break @@ -934,7 +1366,8 @@ async def scan_and_enter() -> list[dict]: logger.info( f"[DayEntry] 스캔 완료: 후보 {len(candidates)}종목 중 {scanned}종목 스캔" f"{f' (미스캔 {truncated})' if truncated else ''}, {len(entries)}진입, " - f"{no_signal}신호없음, {skipped_held}보유중, {skipped_closed}청산완료 " + f"{no_signal}신호없음, {skipped_held}보유중, {skipped_closed}청산완료, " + f"{skipped_other_strategy}타전략보유 " f"(선조회 {prefetched}/{len(candidates)}종목 {_prefetch_sec:.1f}초, " f"총 {time.monotonic() - _scan_started:.1f}초)" ) @@ -1039,6 +1472,13 @@ async def _try_enter( if not signal: return None + # [하드닝 R2] §5.1 가격 필터 — 자동 목록은 일간 스냅샷 기준으로만 걸렀고 수동 목록은 + # 필터가 없었다. 신호가 난 종목만 현재가로 재검사한다(ETF/ETN/리츠 예외는 trade_executor 와 동일 정의). + _px_block = _price_filter_block_v2(stock_name, current_price) + if _px_block: + logger.info(f"[DayEntry] ⛔ {stock_code} {_px_block} — 진입 스킵") + return None + # [P1] confidence 기반 포지션 사이징 — 신호 품질에 따라 예산 조정 current_day_trade_amount = sum( pos.get("entry_price", 0) * pos.get("quantity", 0) @@ -1065,6 +1505,12 @@ async def _try_enter( if quantity <= 0: return None + # [하드닝 R2] §5.1 레버리지/인버스 ETF 20% — 이번 주문 포함, 검증 불가면 차단. + _lev_block = _leverage_block_v2(stock_code, stock_name, budget, total_assets) + if _lev_block: + logger.warning(f"[DayEntry] ⛔ {stock_code} {stock_name}: {_lev_block} — 진입 스킵") + return None + # MCP RISK_ASSESSMENT (선택적) try: from app.api.endpoints.mcp import request_mcp_risk_assessment @@ -1091,6 +1537,39 @@ async def _try_enter( logger.info(f"[DayEntry] MCP 거부: {stock_code} — {mcp_reason}") return None + # [R1-2 2026-10-09] 주문 직전 신선가 재검증. 위 판정의 current_price 는 선조회 분봉의 + # 마지막 종가라 최대 수 분 묵을 수 있고, 손절·목표가 그 값으로 절대가 고정된 뒤 시장가로 + # 나간다. 현재가를 1회 조회해 **같은 신호 함수**로 다시 판정하고(밴드·돌파 마진·BB 하단), + # 통과하면 신선가 기준의 손절·목표·수량으로 교체한다. 탈락하면 진입하지 않는다. + # 괴리 상한(예: 0.3%) 같은 새 임계값은 두지 않는다 — 값은 백테스트로 정할 사항. + # 조회 실패면 기존 동작(분봉 종가)을 유지한다(warning). + _fresh = await _fetch_fresh_price_v2_impl(stock_code) + if _fresh is not None and _fresh != current_price: + _rechecked = _recheck_signal_with_fresh_price_v2( + signal, stock_code, stock_name, + candles=candles, candles_today=candles_today, candles_5m=candles_5m, + vwap=vwap, orb=orb, fresh_price=_fresh, + ) + if _rechecked is None: + logger.info( + f"[DayEntry] ⛔ {stock_code} 신선가 재검증 탈락 — {signal.strategy} " + f"분봉가={current_price:,.0f} → 현재가={_fresh:,.0f} " + f"({(_fresh - current_price) / current_price * 100:+.2f}%)" + ) + return None + _fresh_qty = int(budget // _fresh) if _fresh > 0 else 0 + # MCP 가 승인한 수량을 넘지 않는다(가격 하락으로 수량이 늘어나는 경우) + quantity = min(quantity, _fresh_qty) + if quantity <= 0: + return None + logger.info( + f"[DayEntry] {stock_code} 신선가 재검증 통과 — 분봉가={current_price:,.0f} → " + f"현재가={_fresh:,.0f}, 손절 {signal.stop_loss:,.0f}→{_rechecked.stop_loss:,.0f}, " + f"목표 {signal.target_price:,.0f}→{_rechecked.target_price:,.0f}, 수량={quantity}" + ) + signal = _rechecked + current_price = _fresh + # [H2/M5 2026-10-02] 주문 직전 재검사 + 슬롯 예약 (trade_lock 안). # 신호 판정 ~ 주문 사이에 분봉 조회·MCP 평가 등 await 가 있어 그 사이 다른 경로 # (수동 /cycle, 같은 사이클의 앞선 진입)가 슬롯·예산을 소비할 수 있다. 보유·당일청산· @@ -1098,12 +1577,21 @@ async def _try_enter( # 리스크 가드이므로 try 로 감싸지 않는다(§4) — 바깥 try 는 로그용. _reject = None async with day_trading_state.trade_lock: + _prune_unknown_buys_for_today() if stock_code in day_trading_state.positions or stock_code in _pending_entries: _reject = "이미 보유/주문 진행 중" elif day_trading_state.is_closed_today(stock_code): _reject = "당일 청산 종목" + elif _non_dt_holding_reason_v2(stock_code): + _reject = _non_dt_holding_reason_v2(stock_code) + elif _price_filter_block_v2(stock_name, current_price): + _reject = _price_filter_block_v2(stock_name, current_price) elif day_trading_state.position_count() + len(_pending_entries) >= MAX_POSITIONS: _reject = f"포지션 한도 {MAX_POSITIONS} (주문 진행 중 포함)" + elif _portfolio_cap_block_v2(stock_code): + # [하드닝 루프 R1] §5.1 섹터캡·보유수 상한(플래그, 기본 OFF) — 락 안이라 + # 같은 사이클 앞선 진입(_pending_entries)까지 반영된다. + _reject = _portfolio_cap_block_v2(stock_code) else: _amt_now = sum( pos.get("entry_price", 0) * pos.get("quantity", 0) @@ -1145,95 +1633,659 @@ async def _try_enter( async def _place_and_record_entry_impl( stock_code: str, stock_name: str, signal, quantity: int, current_price: float, ) -> dict | None: - """[H2] 매수 주문 + 체결 후 상태 반영 (shield 구간). 슬롯 예약은 끝에서 해제한다.""" + """[H2] 매수 주문 + 체결 후 상태 반영 (shield 구간). 슬롯 예약은 끝에서 해제한다. + + [R1-1 2026-10-09] 주문 결과를 accepted / rejected / unknown 으로 나눈다(청산 쪽 + `exit_service._classify_order_response` 와 같은 분류). **unknown(전송 후 응답 유실·응답 + 없음·래퍼 예외)은 실패가 아니다** — KIS 가 받았을 수 있으므로 당일 재진입을 막고 + (closed_today), 슬롯·예산 예약을 유지한 채 백그라운드에서 당일 주문내역·잔고로 판정한다 + (`_register_unknown_buy_v2_impl` → `_resolve_unknown_buy_v2_impl`). + """ + keep_reservation = False try: # KIS API 매수 주문 (시장가) — 분산락+세마포어 보호 from app.services.trade_executor import safe_place_order - order_resp = await safe_place_order( - stock_code=stock_code, - order_type="BUY", - quantity=quantity, - price=None, # 시장가 - ) + since = datetime.now(_KST).replace(tzinfo=None) + try: + order_resp = await safe_place_order( + stock_code=stock_code, + order_type="BUY", + quantity=quantity, + price=None, # 시장가 + ) + except Exception as _oe: + # safe_place_order 는 내부에서 예외를 dict 로 감싼다. 그래도 새어 나왔다면 전송 여부를 + # 알 수 없으므로 '불명'으로 다룬다(거부로 보면 같은 종목을 다시 산다). + logger.error( + f"[DayEntry] 매수 주문 호출 예외 ({stock_code}) — 결과 불명으로 처리: " + f"{type(_oe).__name__}: {_oe}" + ) + order_resp = {"rt_cd": "-1", "msg_cd": _ENTRY_TRANSPORT_UNKNOWN_MSG_CD, + "msg1": f"exception: {type(_oe).__name__}"} - if not order_resp or order_resp.get("rt_cd") != "0": - err = order_resp.get("msg1", "unknown") if order_resp else "no response" - logger.error(f"[DayEntry] 매수 주문 실패 ({stock_code}): {err}") + outcome = _classify_entry_order_response_v2(order_resp) + if outcome == "unknown": + keep_reservation = await _register_unknown_buy_v2_impl( + stock_code, stock_name, signal, quantity, current_price, since, order_resp, + ) + return None + if outcome != "accepted": + err = order_resp.get("msg1", "unknown") if isinstance(order_resp, dict) else "no response" + logger.error(f"[DayEntry] 매수 주문 거부 ({stock_code}): {err}") return None - order_id = order_resp.get("output", {}).get("ODNO", "") - - # 인메모리 포지션 추가 (DB 직접 접근 금지) - async with day_trading_state.trade_lock: - day_trading_state.add_position(stock_code, { - "stock_name": stock_name, - "strategy": signal.strategy, - "entry_price": current_price, - "stop_loss": signal.stop_loss, - "target_price": signal.target_price, - "quantity": quantity, - "highest_price": current_price, - "entry_time": datetime.now(_KST).replace(tzinfo=None), - "reason": signal.reason, - "order_id": order_id, - }) - # 예약 → 실제 포지션으로 전환(같은 락 안에서 — 한도 이중 계상 방지) - _pending_entries.pop(stock_code, None) - - # DB 기록 (큐 경유) - await enqueue_trade_history({ - "stock_code": stock_code, - "stock_name": stock_name, - "order_type": "BUY", - "order_price": current_price, - "order_quantity": quantity, - "executed_price": current_price, - "executed_quantity": quantity, - "total_amount": current_price * quantity, - "status": "COMPLETED", - "order_id": order_id, - "entry_reason": signal.reason, - "strategy_type": "daytrading", - }) - # 포트폴리오 화면 즉시 반영 (strategy_type='daytrading' 태깅 → 스윙 청산 로직 대상에서 제외) - from app.services.portfolio_cache_bridge import reflect_buy - await reflect_buy(stock_code, stock_name, quantity, current_price, strategy_type="daytrading") - await enqueue_active_trade_upsert({ - "stock_code": stock_code, - "stock_name": stock_name, - "quantity": quantity, - "entry_price": current_price, - "strategy_name": "day_trade", - "entry_reason": signal.reason, - "stop_loss": signal.stop_loss, - "take_profit": signal.target_price, - }) - - # [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 위 기록의 executed_price 는 - # 신호 시점 1분봉 종가다. 확인되면 원장·인메모리 진입가·ActiveTrade 를 체결가로 보정하고, - # 실패하면 신호가 기록이 그대로 남는다(warning). - _schedule_buy_fill_reconcile(stock_code, order_id, current_price, quantity) - - logger.info( - f"[DayEntry] ✅ 진입: {stock_code} {stock_name} " - f"수량={quantity} 가격={current_price:,.0f} 신호={signal.strategy}" + order_id = (order_resp.get("output") or {}).get("ODNO", "") + return await _record_accepted_entry_impl( + stock_code, stock_name, signal, quantity, current_price, order_id, ) - return { - "stock_code": stock_code, - "stock_name": stock_name, - "quantity": quantity, - "price": current_price, - "strategy": signal.strategy, - "reason": signal.reason, - } - except Exception as e: logger.error(f"[DayEntry] 매수 주문/반영 오류 ({stock_code}): {e}", exc_info=True) return None finally: + if not keep_reservation: + _pending_entries.pop(stock_code, None) + + +async def _record_accepted_entry_impl( + stock_code: str, stock_name: str, signal, quantity: int, current_price: float, + order_id: str, entry_time: datetime | None = None, reason_suffix: str = "", +) -> dict | None: + """접수된 매수 주문 → 인메모리 포지션 + TradeHistory + ActiveTrade(day_trade) + 체결가 확인. + + 정상 접수(rt_cd=0)와 결과 불명 → 주문내역으로 접수 확인된 경로가 공유한다. + 슬롯 예약은 포지션 등록과 같은 락 안에서 해제한다(한도 이중 계상 방지). + """ + _reason = f"{signal.reason}{reason_suffix}" + _entry_time = entry_time or datetime.now(_KST).replace(tzinfo=None) + # 인메모리 포지션 추가 (DB 직접 접근 금지) + async with day_trading_state.trade_lock: + day_trading_state.add_position(stock_code, { + "stock_name": stock_name, + "strategy": signal.strategy, + "entry_price": current_price, + "stop_loss": signal.stop_loss, + "target_price": signal.target_price, + "quantity": quantity, + "highest_price": current_price, + "entry_time": _entry_time, + "reason": _reason, + "order_id": order_id, + }) + # 예약 → 실제 포지션으로 전환(같은 락 안에서 — 한도 이중 계상 방지) _pending_entries.pop(stock_code, None) + # DB 기록 (큐 경유) + await enqueue_trade_history({ + "stock_code": stock_code, + "stock_name": stock_name, + "order_type": "BUY", + "order_price": current_price, + "order_quantity": quantity, + "executed_price": current_price, + "executed_quantity": quantity, + "total_amount": current_price * quantity, + "status": "COMPLETED", + "order_id": order_id, + "entry_reason": _reason, + "strategy_type": "daytrading", + # [R8 2026-10-10] 체결 시각(KST naive) — 없으면 sync_portfolio 의 executed_at 시간창 + # 조회(오늘 마지막 BUY ghost 보존·최근 체결 팬텀 close 유예)가 DT 체결을 보지 못했다 + # (운영 10/01 이후 DT 58행 전부 NULL). 결과불명→확인 경로는 주문 전송 시각(since). + "executed_at": _entry_time, + }) + # 포트폴리오 화면 즉시 반영 (strategy_type='daytrading' 태깅 → 스윙 청산 로직 대상에서 제외) + from app.services.portfolio_cache_bridge import reflect_buy + await reflect_buy(stock_code, stock_name, quantity, current_price, strategy_type="daytrading") + await enqueue_active_trade_upsert({ + "stock_code": stock_code, + "stock_name": stock_name, + "quantity": quantity, + "entry_price": current_price, + "strategy_name": "day_trade", + "entry_reason": _reason, + "stop_loss": signal.stop_loss, + "take_profit": signal.target_price, + # [R3 2026-10-09] ODNO 영속화 — 재기동 복원 포지션(_active_trade_row_to_position)이 + # 빈 order_id 로 만들어지면 불명 매수 판정의 known ODNO 제외·체결가 보정 콜백의 + # 주문 동일성 검사가 복원 포지션에서 항상 빗나간다(운영 154/154 NULL). + # ORM 컬럼(ActiveTrade.order_id)이 이미 있어 db_worker 계약은 그대로다. + "order_id": order_id, + }) + + # [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 위 기록의 executed_price 는 + # 신호 시점 가격이다. 확인되면 원장·인메모리 진입가·ActiveTrade 를 체결가로 보정하고, + # 실패하면 신호가 기록이 그대로 남는다(warning). + _schedule_buy_fill_reconcile(stock_code, order_id, current_price, quantity) + + logger.info( + f"[DayEntry] ✅ 진입: {stock_code} {stock_name} " + f"수량={quantity} 가격={current_price:,.0f} 신호={signal.strategy}{reason_suffix}" + ) + + return { + "stock_code": stock_code, + "stock_name": stock_name, + "quantity": quantity, + "price": current_price, + "strategy": signal.strategy, + "reason": _reason, + } + + +# ── [R1-2 2026-10-09] 주문 직전 신선가 ──────────────────────────────────────── +# 현재가 1회 조회 상한(초). 신호가 난 종목에서만 호출되므로 스캔 예산 영향은 작다. +_FRESH_PRICE_TIMEOUT_SEC: float = 5.0 + + +async def _fetch_fresh_price_v2_impl(stock_code: str) -> float | None: + """주문 직전 현재가(stck_prpr). 실패·0 이면 None(warning) — 호출부는 분봉 종가를 유지한다.""" + try: + resp = await asyncio.wait_for( + korea_investment_api.get_current_price(stock_code), timeout=_FRESH_PRICE_TIMEOUT_SEC, + ) + except asyncio.TimeoutError: + warn_throttled( + logger, f"entry_service:fresh_price:{stock_code}", + f"[DayEntry] 주문 직전 현재가 {_FRESH_PRICE_TIMEOUT_SEC:.0f}초 초과 ({stock_code}) — 분봉 종가로 진행", + ) + return None + except Exception as e: + warn_throttled( + logger, f"entry_service:fresh_price:{stock_code}", + f"[DayEntry] 주문 직전 현재가 조회 실패 ({stock_code}): {type(e).__name__}: {e} — 분봉 종가로 진행", + ) + return None + try: + price = float(((resp or {}).get("output") or {}).get("stck_prpr", 0) or 0) + except (TypeError, ValueError, AttributeError) as e: + warn_throttled( + logger, f"entry_service:fresh_price:{stock_code}", + f"[DayEntry] 현재가 응답 파싱 실패 ({stock_code}): {e} — 분봉 종가로 진행", + ) + return None + return price if price > 0 else None + + +def _recheck_signal_with_fresh_price_v2( + signal, stock_code: str, stock_name: str, *, + candles: list, candles_today: list, candles_5m: list, vwap, orb, fresh_price: float, +): + """발화한 신호와 같은 함수로 신선가를 다시 판정한다. 탈락이면 None. + + 신호 함수는 (봉, 지표, 현재가) 의 순수 함수이고 손절·목표를 현재가 기준으로 계산하므로, + 통과한 새 신호의 손절·목표가 곧 신선가 기준값이다(최소 손절폭 등 §7 규칙 그대로). + 알 수 없는 전략명이면 원래 신호를 그대로 돌려준다(warning) — 재검증 불가가 진입 차단 사유는 아니다. + """ + strat = str(getattr(signal, "strategy", "") or "") + if strat == "ORB": + if not orb: + return None + _orb_input = candles if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED else candles_today + return check_orb_signal(stock_code, stock_name, _orb_input, orb, fresh_price) + if strat == "VWAP_BOUNCE": + if not vwap: + return None + return check_vwap_bounce_signal(stock_code, stock_name, candles_5m, vwap, fresh_price) + if strat == "RSI_BB": + return check_rsi_bb_signal(stock_code, stock_name, candles_5m, fresh_price) + logger.warning(f"[DayEntry] 신선가 재검증: 알 수 없는 전략명 {strat!r} ({stock_code}) — 원 신호 유지") + return signal + + +# ── [R1-1 2026-10-09] 결과 불명 매수 ────────────────────────────────────────── +# safe_place_order 가 전송 후 응답을 잃으면 {rt_cd:"-1", msg_cd:"KIS_TRANSPORT_UNKNOWN"} 를 +# 돌려준다. 예전에는 이것을 거부처럼 다뤄 (a) 다음 사이클에 같은 종목을 다시 사고 +# (b) 실제로 접수된 물량은 인메모리·ActiveTrade 가 없어 손절·EOD 없이 남았다(고아 보유). +# +# 처리: 당일 재진입 차단(closed_today) + 슬롯·예산 예약 유지 → 백그라운드에서 +# ① 당일 주문내역에 이 주문(매수·수량 일치·주문 시각 이후·미사용 ODNO)이 있으면 접수 → +# 정상 진입과 같은 기록(인메모리·TradeHistory·ActiveTrade(day_trade)) + 체결가 확인 +# (체결 0 종결이면 fill 보정이 포지션을 제거한다). +# ② 주문내역 조회가 **성공**했는데 행이 없고 KIS 잔고에 그 종목이 0주면 미접수 → 예약 해제 +# (재진입 차단은 당일 유지) + 감시 창 동안 재확인(R4 — 아래 _watch_released_unknown_buy_v2_impl). +# ③ 조회 실패·잔고 조회 실패는 라운드 간격을 두고 재판정, 끝내 불가면(또는 잔고에 같은 종목이 +# 있는데 주문내역 미확인 — 스윙 보유분과 구별 불가) 예약 유지 + error + 텔레그램 수동 확인 요청. +# get_todays_orders 는 조회 실패에도 [] 를 돌려주므로 쓰지 않는다(_fetch_todays_orders_strict_v2_impl). +_ENTRY_TRANSPORT_UNKNOWN_MSG_CD = "KIS_TRANSPORT_UNKNOWN" +# {(KST 날짜 iso, 종목코드): {"qty", "since", "price", "stock_name", "signal", "status", "reserved"}} +_unknown_buy_entries: dict[tuple[str, str], dict] = {} +_unknown_buy_tasks: set = set() + + +def _classify_entry_order_response_v2(resp) -> str: + """주문 응답 → "accepted" | "rejected" | "unknown" (청산 쪽 분류의 정본을 그대로 쓴다).""" + from app.services.daytrading.exit_service import _classify_order_response + return _classify_order_response(resp) + + +def _today_iso() -> str: + return datetime.now(_KST).date().isoformat() + + +def _prune_unknown_buys_for_today() -> None: + """날짜가 바뀐 불명 매수 상태와 그 예약을 버린다. trade_lock 안에서 호출한다.""" + today = _today_iso() + for key in [k for k in _unknown_buy_entries if k[0] != today]: + ent = _unknown_buy_entries.pop(key, None) or {} + if ent.get("reserved"): + _pending_entries.pop(key[1], None) + + +def get_unknown_buy_entries() -> dict[str, dict]: + """관측용 — 당일 결과 불명 매수 {종목코드: {qty, since, status}} (읽기 전용 사본).""" + today = _today_iso() + return { + k[1]: {"qty": v.get("qty"), "since": v.get("since"), "status": v.get("status")} + for k, v in _unknown_buy_entries.items() if k[0] == today + } + + +async def _register_unknown_buy_v2_impl( + stock_code: str, stock_name: str, signal, quantity: int, current_price: float, + since: datetime, order_resp, +) -> bool: + """결과 불명 매수 등록 — 재진입 차단 + 예약 유지 + 판정 예약. 예약을 유지하면 True. + + 가드(재진입 차단·예약 유지)는 try 밖에서 먼저 확정한다(§4). 판정 태스크 예약 실패는 + 예약을 유지한 채 error 로 남긴다(수동 확인). + """ + key = (_today_iso(), stock_code) + async with day_trading_state.trade_lock: + day_trading_state.closed_today.add(stock_code) + _unknown_buy_entries[key] = { + "qty": int(quantity), "since": since, "price": float(current_price), + "stock_name": stock_name, "signal": signal, "status": "pending", "reserved": True, + } + _msg = (order_resp or {}).get("msg_cd") if isinstance(order_resp, dict) else None + logger.error( + f"[DayEntry] 🚨 매수 주문 결과 불명 ({stock_code} {quantity}주, msg_cd={_msg or 'none'}) — " + f"당일 재진입 차단·슬롯 예약 유지, 주문내역·잔고로 접수 여부 확인 예정" + ) + await day_trading_state.save_daily_state() + try: + task = asyncio.get_running_loop().create_task(_resolve_unknown_buy_v2_impl(stock_code, key)) + except RuntimeError as e: + logger.error(f"[DayEntry] 결과 불명 매수 판정 예약 실패 ({stock_code}): {e} — 수동 확인 필요") + return True + _unknown_buy_tasks.add(task) + task.add_done_callback(_unknown_buy_tasks.discard) + return True + + +def _find_unknown_buy_row_v2( + rows, stock_code: str, qty: int, since: datetime, known_odnos: set[str], +) -> tuple[dict | None, bool]: + """당일 주문내역에서 이 불명 매수에 해당하는 행 → (유효 접수 행|None, 거부/취소 행 존재 여부). + + 조건: 종목 일치, 매수(sll_buy_dvsn_cd="02"), 주문수량 일치, 주문시각 ≥ 주문 전 −창, + 이미 DT 포지션이 쓰는 ODNO 아님. 체결 0 이면서 거부·취소된 행은 '접수 아님'의 근거로만 쓴다. + """ + from app.services.daytrading.fill_reconciler import _norm_odno + from datetime import timedelta + _w = float(settings.DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC) + since_hms = (since - timedelta(seconds=_w)).strftime("%H%M%S") + dead_seen = False + for r in rows or []: + if not isinstance(r, dict): + continue + if str(r.get("pdno", stock_code) or stock_code) != stock_code: + continue + if str(r.get("sll_buy_dvsn_cd", "")) != "02": + continue + try: + oq = int(float(r.get("ord_qty", 0) or 0)) + cq = int(float(r.get("tot_ccld_qty", 0) or 0)) + rj = int(float(r.get("rjct_qty", 0) or 0)) + except (TypeError, ValueError): + continue + if oq != int(qty): + continue + t = str(r.get("ord_tmd", "") or "") + if t and t < since_hms: + continue + odno = _norm_odno(r.get("odno", "")) + if odno and odno in known_odnos: + continue + if cq == 0 and (rj > 0 or str(r.get("cncl_yn", "") or "").upper() == "Y"): + dead_seen = True + continue + return r, dead_seen + return None, dead_seen + + +async def _release_unknown_buy_reservation_impl(stock_code: str, key: tuple[str, str], status: str) -> None: + async with day_trading_state.trade_lock: + ent = _unknown_buy_entries.get(key) + if ent is not None: + ent["status"] = status + if ent.get("reserved"): + ent["reserved"] = False + _pending_entries.pop(stock_code, None) + + +def _alert_unknown_buy_manual_check(stock_code: str, qty: int, detail: str) -> None: + try: + from app.services.telegram_notifier import telegram_notifier + _t = asyncio.get_running_loop().create_task(asyncio.wait_for( + telegram_notifier.notify_system_warning( + "DT_ENTRY_UNKNOWN", + f"{stock_code} 데이트레이딩 매수 {qty}주 결과 불명 — {detail}. " + f"계좌를 확인하고, 보유 중이면 수동 청산하세요(DT 손절·EOD 대상이 아님).", + ), + timeout=10.0, + )) + _unknown_buy_tasks.add(_t) + _t.add_done_callback(_unknown_buy_tasks.discard) + except Exception as e: + logger.error(f"[DayEntry] 결과 불명 매수 경보 발송 실패({stock_code}): {e}") + + +# [R4 2026-10-09] 미접수 확정 근거 강화. +# 이전에는 "주문내역에 행 없음(get_todays_orders — 조회 실패에도 [])" + "잔고 0주" 로 약 8초 안에 +# 미접수를 확정했다. 잔고는 '체결된' 수량만 보여, 접수됐지만 아직 체결되지 않은 시장가 주문 +# (VI 단일가 등)과 미접수를 구분하지 못한다 → 나중에 체결된 물량이 DT 손절·EOD 밖 보유가 된다. +# 지금은: 미접수 확정 = **주문내역 조회 성공(rt_cd=0) 에서 행 없음** + 잔고 0주. +# - 조회가 한 번도 성공하지 못했으면 판정 불가 → 예약 유지, 일정 시간 뒤 재판정(라운드). +# - 확정 후에도 감시 창 동안 주문내역·잔고를 다시 본다. 늦게 행이 나타나면 DT 포지션으로 +# 등록(손절·EOD 관리 하에 둔다), 행 없이 잔고만 나타나면 경보(스윙 매수와 구별 불가). +# 기본값은 settings 에 키가 없을 때의 폴백이다(config.py 편입 전). 전략 파라미터가 아니라 +# 판정 타이밍이다. +_UNKNOWN_BUY_RESOLVE_ROUNDS_DEFAULT = 3 +_UNKNOWN_BUY_RESOLVE_RETRY_SEC_DEFAULT = 60.0 +_UNKNOWN_BUY_WATCH_CHECKS_DEFAULT = 5 +_UNKNOWN_BUY_WATCH_INTERVAL_SEC_DEFAULT = 60.0 + + +def _unknown_buy_timing_v2() -> tuple[int, float, int, float]: + """(재판정 라운드 수, 라운드 간격초, 확정 후 감시 횟수, 감시 간격초).""" + rounds = max(1, int(getattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_RESOLVE_ROUNDS", + _UNKNOWN_BUY_RESOLVE_ROUNDS_DEFAULT))) + retry = max(0.0, float(getattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_RESOLVE_RETRY_SEC", + _UNKNOWN_BUY_RESOLVE_RETRY_SEC_DEFAULT))) + checks = max(0, int(getattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_WATCH_CHECKS", + _UNKNOWN_BUY_WATCH_CHECKS_DEFAULT))) + w_int = max(0.0, float(getattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_WATCH_INTERVAL_SEC", + _UNKNOWN_BUY_WATCH_INTERVAL_SEC_DEFAULT))) + return rounds, retry, checks, w_int + + +async def _fetch_todays_orders_strict_v2_impl(stock_code: str) -> list | None: + """당일 주문내역 — 조회 성공(rt_cd=0)이면 행 리스트(빈 리스트 포함), 실패·시간초과·예외면 None. + + `get_todays_orders` 는 실패에도 [] 를 돌려줘 '행 없음'과 '조회 실패'가 섞인다. 미접수 확정은 + '성공한 빈 응답'에서만 내려야 하므로 같은 요청(_daily_ccld_fetch_v2)을 직접 부른다. + BaseException(테스트 차단 등)은 삼키지 않는다. + """ + _timeout = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + try: + result, _ = await asyncio.wait_for( + korea_investment_api._daily_ccld_fetch_v2(stock_code=stock_code), timeout=_timeout, + ) + except asyncio.TimeoutError: + logger.warning(f"[DayEntry] 불명 매수 확인: 주문내역 조회 시간초과 ({stock_code})") + return None + except Exception as e: + logger.warning(f"[DayEntry] 불명 매수 확인: 주문내역 조회 실패 ({stock_code}): {type(e).__name__}: {e}") + return None + if not isinstance(result, dict) or result.get("rt_cd") != "0": + _r = result if isinstance(result, dict) else {} + logger.warning( + f"[DayEntry] 불명 매수 확인: 주문내역 조회 실패 ({stock_code}) " + f"rt_cd={_r.get('rt_cd')} msg_cd={_r.get('msg_cd')}" + ) + return None + out = result.get("output1") or [] + return out if isinstance(out, list) else [out] + + +def _known_dt_odnos_v2() -> set[str]: + from app.services.daytrading.fill_reconciler import _norm_odno + return { + _norm_odno(p.get("order_id", "")) + for p in day_trading_state.positions.values() if p.get("order_id") + } + + +async def _accept_unknown_buy_row_v2_impl( + stock_code: str, key: tuple[str, str], ent: dict, row: dict, *, late: bool, +) -> str: + """주문내역에서 찾은 접수 행 → DT 포지션 등록. 이미 포지션이 있으면 충돌 경보.""" + qty = int(ent["qty"]) + odno = str(row.get("odno", "") or "") + if stock_code in day_trading_state.positions: + # 다른 경로(복원 등)가 이미 포지션을 만들었다 — 덮어쓰지 않는다 + await _release_unknown_buy_reservation_impl(stock_code, key, "accepted_conflict") + logger.error( + f"[DayEntry] 🚨 {stock_code} 불명 매수가 접수로 확인됐으나(ODNO={odno}) 이미 DT 포지션이 있다 " + f"— 수량 합산하지 않음, 수동 확인 필요" + ) + _alert_unknown_buy_manual_check(stock_code, qty, f"접수 확인(ODNO={odno}) / 기존 포지션과 충돌") + return "unresolved" + if late: + logger.error( + f"[DayEntry] 🚨 {stock_code} 미접수로 판정했던 불명 매수가 주문내역에 나타났다(ODNO={odno}, {qty}주) " + f"— DT 포지션으로 등록해 손절·EOD 관리 하에 둔다" + ) + _alert_unknown_buy_manual_check( + stock_code, qty, f"미접수 판정 후 주문내역에서 접수 확인(ODNO={odno}) — DT 포지션으로 등록함", + ) + else: + logger.warning( + f"[DayEntry] {stock_code} 결과 불명 매수 → 접수로 판정 (ODNO={odno}, {qty}주) — 포지션 등록" + ) + async with day_trading_state.trade_lock: + ent["status"] = "late_accepted" if late else "accepted" + ent["reserved"] = False # 예약은 _record_accepted_entry_impl 이 같은 락에서 포지션으로 전환 + await _record_accepted_entry_impl( + stock_code, ent["stock_name"], ent["signal"], qty, float(ent["price"]), odno, + entry_time=ent["since"], + reason_suffix=" [결과불명→주문내역 확인(지연)]" if late else " [결과불명→주문내역 확인]", + ) + return "late_accepted" if late else "accepted" + + +async def _resolve_unknown_buy_v2_impl(stock_code: str, key: tuple[str, str]) -> str: + """결과 불명 매수 판정 → "accepted" | "not_accepted" | "unresolved" | "gone".""" + ent = _unknown_buy_entries.get(key) + if ent is None: + return "gone" + qty = int(ent["qty"]) + since = ent["since"] + rounds, round_retry, _, _ = _unknown_buy_timing_v2() + + await asyncio.sleep(max(0.0, float(settings.DAY_TRADE_FILL_CHECK_DELAY_SEC))) + attempts = max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)) + interval = max(0.0, float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC)) + detail = "" + for rnd in range(rounds): + if _unknown_buy_entries.get(key) is not ent: + return "gone" # 날짜 변경 prune 등 + row, dead_seen, orders_ok = None, False, False + for i in range(attempts): + rows = await _fetch_todays_orders_strict_v2_impl(stock_code) + if rows is not None: + orders_ok = True + row, _dead = _find_unknown_buy_row_v2(rows, stock_code, qty, since, _known_dt_odnos_v2()) + dead_seen = dead_seen or _dead + if row is not None: + break + if i < attempts - 1: + await asyncio.sleep(interval) + + if row is not None: + return await _accept_unknown_buy_row_v2_impl(stock_code, key, ent, row, late=False) + + # 주문내역에서 못 찾음 — 잔고로 '체결분 없음'을 확인한다 + from app.services.daytrading.exit_service import _fetch_kis_holdings_v2_impl + holdings = await _fetch_kis_holdings_v2_impl() + hldg = None if holdings is None else int((holdings.get(stock_code) or {}).get("hldg", 0) or 0) + if hldg == 0 and orders_ok: + await _release_unknown_buy_reservation_impl(stock_code, key, "not_accepted") + logger.warning( + f"[DayEntry] {stock_code} 결과 불명 매수 → 미접수로 판정(주문내역 조회 성공·행 없음" + f"{'·거부/취소 행 확인' if dead_seen else ''}, 잔고 0주) — 슬롯 예약 해제, 당일 재진입 차단은 유지" + ) + if not dead_seen: + _schedule_unknown_buy_watch_v2(stock_code, key) + return "not_accepted" + if hldg is not None and hldg > 0 and orders_ok: + detail = f"주문내역에 없음·잔고 {hldg}주(스윙 보유분과 구별 불가)" + break # 재판정해도 구별 근거가 생기지 않는다 + detail = ("잔고 조회 실패" if hldg is None + else f"주문내역 조회 실패·잔고 {hldg}주(접수·미체결 주문과 미접수를 구별 불가)") + if rnd < rounds - 1: + async with day_trading_state.trade_lock: + ent["status"] = "retrying" + logger.warning( + f"[DayEntry] {stock_code} 결과 불명 매수 판정 보류({detail}) — 예약 유지, " + f"{round_retry:.0f}초 뒤 재판정 ({rnd + 1}/{rounds})" + ) + await asyncio.sleep(round_retry) + + async with day_trading_state.trade_lock: + if _unknown_buy_entries.get(key) is not ent: + return "gone" + ent["status"] = "unresolved" + logger.error( + f"[DayEntry] 🚨 {stock_code} 결과 불명 매수 판정 불가 — {detail}. 슬롯 예약 유지·재진입 차단, " + f"수동 확인 필요(접수됐다면 DT 손절·EOD 대상이 아니다)" + ) + _alert_unknown_buy_manual_check(stock_code, qty, detail) + return "unresolved" + + +def _schedule_unknown_buy_watch_v2(stock_code: str, key: tuple[str, str]) -> None: + """미접수 확정 후 감시 태스크 예약. 실패는 error(감시 없이 진행).""" + _, _, checks, _ = _unknown_buy_timing_v2() + if checks <= 0: + return + try: + task = asyncio.get_running_loop().create_task(_watch_released_unknown_buy_v2_impl(stock_code, key)) + except RuntimeError as e: + logger.error(f"[DayEntry] 미접수 판정 후 감시 예약 실패 ({stock_code}): {e} — 계좌 수동 확인 권장") + return + _unknown_buy_tasks.add(task) + task.add_done_callback(_unknown_buy_tasks.discard) + + +async def _watch_released_unknown_buy_v2_impl(stock_code: str, key: tuple[str, str]) -> str: + """미접수 확정 뒤 감시 → "late_accepted" | "holding_alert" | "unresolved" | "clean" | "gone". + + - 주문내역에 이 주문 행이 나타나면(조회 지연·늦은 반영) DT 포지션으로 등록한다 — 손절·EOD + 관리 밖 보유를 막는 쪽이 안전하다. 체결 0 종결이면 체결가 보정이 포지션을 제거한다. + - 행 없이 잔고에 이 종목이 나타나면 경보만 보낸다(스윙 매수와 구별 불가 — 임의 등록 금지). + """ + ent = _unknown_buy_entries.get(key) + if ent is None: + return "gone" + _, _, checks, w_int = _unknown_buy_timing_v2() + qty = int(ent["qty"]) + for _ in range(checks): + await asyncio.sleep(w_int) + if _unknown_buy_entries.get(key) is not ent or ent.get("status") != "not_accepted": + return "gone" + rows = await _fetch_todays_orders_strict_v2_impl(stock_code) + row, _dead = _find_unknown_buy_row_v2(rows, stock_code, qty, ent["since"], _known_dt_odnos_v2()) + if row is not None: + return await _accept_unknown_buy_row_v2_impl(stock_code, key, ent, row, late=True) + if _dead: + return "clean" + from app.services.daytrading.exit_service import _fetch_kis_holdings_v2_impl + holdings = await _fetch_kis_holdings_v2_impl() + if holdings is None: + continue + hldg = int((holdings.get(stock_code) or {}).get("hldg", 0) or 0) + if hldg > 0 and stock_code not in day_trading_state.positions: + async with day_trading_state.trade_lock: + ent["status"] = "holding_after_not_accepted" + logger.error( + f"[DayEntry] 🚨 {stock_code} 미접수로 판정했던 불명 매수 뒤 잔고 {hldg}주가 나타났다 " + f"(주문내역 미확인 — 스윙 매수와 구별 불가). DT 손절·EOD 대상이 아니다 — 수동 확인 필요" + ) + _alert_unknown_buy_manual_check(stock_code, qty, f"미접수 판정 후 잔고 {hldg}주 발견") + return "holding_alert" + return "clean" + + +def _dt_inmem_qty(stock_code: str) -> int: + """DT 인메모리 포지션 수량(없으면 0).""" + pos = day_trading_state.positions.get(stock_code) + if not pos: + return 0 + try: + return max(0, int(pos.get("quantity", 0) or 0)) + except (TypeError, ValueError): + return 0 + + +async def _revert_unfilled_buy_portfolio_v2_impl( + stock_code: str, unfilled_qty: int, price: float, floor_qty: int, +) -> int | None: + """[R3 2026-10-09] 매수 0체결·부분체결 종결 시 reflect_buy 반영분 중 미체결 수량을 되돌린다. + + - 대상은 **'daytrading' 태그 행만**이다. 다른 전략 보유분이 섞였을 수 있는 행(swing/external/ + synced 등)은 건드리지 않고 sync_portfolio 에 맡긴다(§3.5 — 타 전략 수량을 깎지 않는다). + 고아 감지(_detect_dt_orphans_impl)도 DT 태그 행만 후보로 삼으므로 경보 오발은 이것으로 막힌다. + - 잔량 = max(floor_qty, Portfolio 수량 − 미체결). floor_qty(=DT 인메모리 잔량) 아래로는 내리지 + 않는다 — 그 사이 sync 가 이미 실잔고로 맞춰 놓았으면 이중 차감이 되기 때문이다. + 잔량은 기존 수량을 넘지 않는다(되돌림이 수량을 늘리는 일은 없다). + - reflect_sell 은 쓰지 않는다: note_sell(최근 매도 기록)이 5분간 sync 의 실보유 행을 + '반영 지연'으로 오판해 지울 수 있고, 매도가 아니라 매수 취소이기 때문이다. + - 쓰기는 db_worker 큐 경유(§3.2). 수량 조회는 bridge 의 읽기 전용 헬퍼를 그대로 쓴다. + 반환: 기록한 잔량, 건너뛰었으면 None. 실패는 warning — 화면·장부 보정이며 리스크 가드가 아니다 + (실패 시 수량이 과대로 남아 누적 4% 한도가 더 보수적으로 작동할 뿐이다). + """ + try: + unfilled = int(unfilled_qty or 0) + except (TypeError, ValueError): + unfilled = 0 + if unfilled <= 0: + return None + floor = max(0, int(floor_qty or 0)) + try: + from app.services import portfolio_cache_bridge as _pcb + from app.services.db_worker import enqueue_portfolio_upsert, DT_STRATEGY_NAMES + + cache = _pcb._cache() + existing = next((h for h in cache.holdings if h.stock_code == stock_code), None) + if existing is None: + logger.info( + f"[DayEntry] {stock_code} 미체결 {unfilled}주 Portfolio 되돌림 생략 — 캐시 행 없음(sync 반영 추정)" + ) + return None + _tag = str(getattr(existing, "strategy_type", "") or "") + if _tag not in DT_STRATEGY_NAMES: + logger.warning( + f"[DayEntry] {stock_code} 미체결 {unfilled}주 Portfolio 되돌림 생략 — 행 태그 '{_tag or 'NULL'}'" + f"(타 전략 보유분 혼재 가능), sync_portfolio 가 KIS 실잔고로 보정" + ) + return None + db_qty = await _pcb._db_quantity(stock_code) + base = db_qty if db_qty is not None else int(existing.quantity or 0) + remaining = min(base, max(floor, base - unfilled)) + if remaining <= 0: + cache.holdings = [h for h in cache.holdings if h.stock_code != stock_code] + else: + existing.quantity = remaining + await enqueue_portfolio_upsert({ + "stock_code": stock_code, + "quantity": remaining, # 0 → 목록에서 제외, 다음 동기화에서 삭제 + "current_price": price, + }) + _reverted = max(0, base - remaining) + if _reverted > 0 and price > 0: + # reflect_buy 의 apply_buy(예수금 차감)를 같은 가격으로 상쇄 — 손익 영향 0 + await cache.apply_sell(stock_code, _reverted, price, price) + logger.warning( + f"[DayEntry] {stock_code} 매수 미체결 {unfilled}주 → Portfolio(daytrading) {base}→{remaining}주 되돌림" + f" (하한 DT 인메모리 {floor}주)" + ) + return remaining + except Exception as e: + logger.warning(f"[DayEntry] {stock_code} 미체결 Portfolio 되돌림 실패: {type(e).__name__}: {e} — sync 가 보정") + return None + def _schedule_buy_fill_reconcile( stock_code: str, order_id: str, signal_price: float, quantity: int, @@ -1284,6 +2336,11 @@ def _schedule_buy_fill_reconcile( pos = day_trading_state.positions.get(stock_code) if pos is not None and str(pos.get("order_id", "")) == str(order_id): day_trading_state.remove_position(stock_code) # closed_today 포함(당일 재진입 차단) + # [R2-2 2026-10-09] dt_exited_today 는 여기서 지우지 않는다. 화면 '청산 N' 에 + # 0체결 매수가 1건 섞이는 표시 결함(LOW)이 있지만, dt_exited_today 는 + # close_stale_positions 의 당일 active 행 복원 차단 근거 + # (position_manager.restore_block_reason_v2)이기도 하다. 지우면 close 큐 지연 + # 중 잔존 active 행이 복원 후보가 된다 → 표시는 별도 집합으로 분리해야 한다. removed = True await enqueue_update_trade_history_by_order_id(order_id, { **_ident, "executed_quantity": 0, "total_amount": Decimal("0"), @@ -1292,6 +2349,11 @@ def _schedule_buy_fill_reconcile( if removed: await day_trading_state.save_daily_state() await enqueue_close_active_trade(stock_code, strategy_name="day_trade") + # [R3] 진입 때 reflect_buy 로 올린 Portfolio(daytrading) 수량을 되돌린다. + # 남겨 두면 sync 전까지 고아 감지가 '원장 순체결 0주' 수동확인 경보를 오발한다. + await _revert_unfilled_buy_portfolio_v2_impl( + stock_code, quantity, signal_price, _dt_inmem_qty(stock_code), + ) logger.warning( f"[DayEntry] ⚠️ 매수 미체결 종결: {stock_code} 0/{quantity}주 order_id={order_id} " + ("— 인메모리 포지션 제거·ActiveTrade(day_trade) 정리" if removed @@ -1344,6 +2406,11 @@ def _schedule_buy_fill_reconcile( await enqueue_close_active_trade(stock_code, strategy_name="day_trade") else: await enqueue_active_trade_upsert(_at) + if partial_final: + # [R3] 미체결분만큼 Portfolio(daytrading) 반영을 되돌린다(하한 = DT 인메모리 잔량). + await _revert_unfilled_buy_portfolio_v2_impl( + stock_code, quantity - filled_qty, signal_price, _dt_inmem_qty(stock_code), + ) if partial_final: logger.warning( f"[DayEntry] ⚠️ 매수 부분체결 종결: {stock_code} {filled_qty}/{quantity}주 " diff --git a/backend/app/services/daytrading/exit_service.py b/backend/app/services/daytrading/exit_service.py index 606669f..d08bf2c 100644 --- a/backend/app/services/daytrading/exit_service.py +++ b/backend/app/services/daytrading/exit_service.py @@ -20,9 +20,10 @@ - loss_tracker 연동: 전량 매도 시 record_trade_result_safe() + save_to_db() 호출 """ import asyncio +import contextvars import logging import time -from datetime import datetime, timedelta +from datetime import datetime, timedelta, time as _dtime import pytz @@ -79,12 +80,84 @@ _total_assets_refreshed_at: float = 0.0 def get_stale_realized_pnl() -> float: - """전일 포지션 강제청산으로 실현된 손익 누적값(당일 daily_pnl 에서 제외된 금액).""" + """전일 포지션 강제청산으로 실현된 손익 누적값(당일 daily_pnl 에서 제외된 금액). + + 하위호환용 원시 누적값. 날짜 경계 처리는 가산 시점(_add_stale_realized_pnl)에만 하므로, + 날짜가 바뀐 뒤 첫 가산 전에는 전일 값이 남아 보일 수 있다 — 표시용은 _v2 를 쓸 것. + """ return _stale_realized_pnl +# [R7 2026-10-10] _stale_realized_pnl 이 귀속된 KST 날짜. 날짜가 바뀌면 0 으로 되돌린다 +# (종전에는 프로세스 수명 동안 무한 누적돼 '전일 누적'·/close-all 응답이 여러 날의 합이 됐다). +# 매매 판정에는 쓰이지 않는 추적·표시 값이다. +_stale_realized_pnl_date: str | None = None + + +def _roll_stale_realized_pnl_day() -> None: + """날짜가 바뀌었으면 stale 누적값을 0 으로 초기화하고 날짜를 오늘로 맞춘다.""" + global _stale_realized_pnl, _stale_realized_pnl_date + today = _today_key() + if _stale_realized_pnl_date != today: + if _stale_realized_pnl_date is not None and _stale_realized_pnl != 0.0: + logger.info( + f"[DayExit] 전일·고아 청산 손익 누적 일자 변경 {_stale_realized_pnl_date}→{today}: " + f"{_stale_realized_pnl:+.0f}원 → 0 초기화" + ) + if _stale_realized_pnl_date is not None: + _stale_realized_pnl = 0.0 + _stale_realized_pnl_date = today + + +def _add_stale_realized_pnl(delta: float) -> float: + """당일(KST) stale 누적값에 delta 를 더하고 새 누적값을 반환한다.""" + global _stale_realized_pnl + _roll_stale_realized_pnl_day() + _stale_realized_pnl += delta + return _stale_realized_pnl + + +def get_stale_realized_pnl_v2() -> float: + """당일(KST) 전일·고아 청산 실현손익 누적값. 날짜가 바뀌면 0 에서 다시 센다.""" + _roll_stale_realized_pnl_day() + return _stale_realized_pnl + + +# [R2-3 2026-10-09] 국내 ETF/ETN 매도에는 증권거래세가 없다. DT 포지션에는 상품 구분이 없어 +# 종목명으로 판별한다(운용사 브랜드 + 공백으로 시작, 또는 'ETN' 포함). 판별 실패(이름 없음· +# 신규 브랜드)는 주식 세율을 그대로 쓴다 — 순손익을 **낮게**(손실 쪽으로) 보는 기존 동작. +# ⚠️ 해외·채권형 ETF 의 매매차익 배당소득세(15.4%)는 원천징수라 여기서 모델링하지 않는다. +_ETF_BRAND_PREFIXES = ( + "KODEX", "TIGER", "PLUS", "KIWOOM", "RISE", "ACE", "SOL", "HANARO", "KOSEF", + "ARIRANG", "KBSTAR", "TIMEFOLIO", "WOORI", "1Q", "BNK", "FOCUS", "TRUSTON", + "KTOP", "UNICORN", "VITA", "WON", "TREX", "DAISHIN343", "마이티", "에셋플러스", + "파워", "히어로즈", "마이다스", "KCGI", "HK", +) + + +def _is_etf_or_etn_name_impl(stock_name) -> bool: + """종목명으로 국내 ETF/ETN 여부 판별 (브랜드 + 공백 접두 또는 'ETN' 토큰).""" + name = str(stock_name or "").strip() + if not name: + return False + upper = name.upper() + if "ETN" in upper.split() or upper.endswith(" ETN") or " ETN(" in upper: + return True + head = upper.split(" ", 1) + if len(head) < 2: + return False + # 브랜드 토큰 + 공백일 때만 인정(주식명은 대개 공백이 없다). ETN 발행사(삼성·신한·TRUE 등)는 + # 이름에 'ETN' 이 붙어 위에서 걸린다. + return head[0] in _ETF_BRAND_PREFIXES + + def _dt_sell_tax_rate(pos: dict) -> float: - """DT 매도 거래세율 — 포지션에 시장 구분이 없으면 DAY_TRADE_DEFAULT_TAX_MARKET(스윙 기본과 동일).""" + """DT 매도 거래세율 — 포지션에 시장 구분이 없으면 DAY_TRADE_DEFAULT_TAX_MARKET(스윙 기본과 동일). + + [R2-3] 국내 ETF/ETN(종목명 판별)은 증권거래세 0. + """ + if _is_etf_or_etn_name_impl((pos or {}).get("stock_name")): + return 0.0 market = str((pos or {}).get("market") or settings.DAY_TRADE_DEFAULT_TAX_MARKET or "KOSPI").upper() if market == "KOSDAQ": return float(settings.KR_SELL_TAX_RATE_KOSDAQ) @@ -103,6 +176,39 @@ def dt_net_pnl(entry_price: float, exit_price: float, quantity: int, pos: dict) ) +def _sell_fill_pnl_impl( + entry_price: float, avg_price: float, signal_price: float, + filled_qty: int, quantity: int, pos: dict, +) -> dict: + """[H-R1-2 2026-10-09] 매도 체결 확인 결과 → 손익·거래대금 (순수 계산). + + - 전량 체결(filled ≥ quantity): 종전과 동일 — 주문 수량 전체를 체결가로. + - 미종결 부분체결(0 < filled < quantity): 체결분은 실제 체결가, 미체결분은 **신호가 기준 추정** + (청산 접수 시 recorded_net 과 같은 근거)으로 합산한다. 예전에는 미체결분까지 체결가로 계상했다. + 미체결분을 0 으로 빼면 일일 손실한도(daily_pnl)가 그만큼 덜 잡히므로(손실 과소) 추정치를 남긴다. + 거래대금·체결수량은 실제 체결분만. + 두 구간을 수량 가중 혼합 청산가 하나로 묶어 dt_net_pnl 을 1회 호출한다(비용 반올림이 구간별로 + 두 번 일어나지 않게 — 전량 체결 시 종전 값과 비트 동일). + """ + q = max(0, int(quantity)) + f = max(0, min(int(filled_qty), q)) + if f >= q or f <= 0: + r = dt_net_pnl(entry_price, avg_price, q, pos) + return { + "net_profit_loss": r["net_profit_loss"], "total_cost": r["total_cost"], + "net_profit_loss_rate": r["net_profit_loss_rate"], + "total_amount": float(avg_price) * q, "executed_quantity": q, "is_partial": False, + } + _sig = float(signal_price) if signal_price and float(signal_price) > 0 else float(avg_price) + blended_exit = (float(avg_price) * f + _sig * (q - f)) / q + r = dt_net_pnl(entry_price, blended_exit, q, pos) + return { + "net_profit_loss": r["net_profit_loss"], "total_cost": r["total_cost"], + "net_profit_loss_rate": r["net_profit_loss_rate"], + "total_amount": float(avg_price) * f, "executed_quantity": f, "is_partial": True, + } + + def _fallback_stop_pct() -> float: """[M7] stop_loss 가 없을 때 사용할 폴백 손절 비율. @@ -141,6 +247,98 @@ def _kst_now() -> datetime: return datetime.now(_KST).replace(tzinfo=None) +# [R2-1 2026-10-09] KRX 종가 단일가 매매 구간(시장 구조 상수 — 전략 파라미터 아님). +# 이 구간에 낸 시장가 주문은 15:30 에 일괄 체결된다. +_KRX_CLOSING_AUCTION_START = _dtime(15, 20) +_KRX_MARKET_CLOSE = _dtime(15, 30) +# 종가 단일가 재확인 백그라운드 태스크 (GC 방지·테스트 대기용) +_auction_fill_tasks: set = set() + + +def _in_closing_auction_impl(now: datetime) -> bool: + """now(KST naive) 가 종가 단일가 구간(15:20 ≤ t < 15:30)인가.""" + t = now.time() + return _KRX_CLOSING_AUCTION_START <= t < _KRX_MARKET_CLOSE + + +def _auction_recheck_params_impl() -> tuple[float, int, float]: + """(장 마감 후 대기 초, 재확인 횟수, 재확인 간격 초). + + settings 에 DAY_TRADE_FILL_AUCTION_RECHECK_{DELAY_SEC,ATTEMPTS,INTERVAL_SEC} 가 있으면 그 값, + 없으면 기본 30초/3회/30초(config.py 미편입 — 같은 라운드에서 config 를 고치지 않았다). + """ + _delay = max(0.0, float(getattr(settings, "DAY_TRADE_FILL_AUCTION_RECHECK_DELAY_SEC", 30.0))) + _attempts = max(1, int(getattr(settings, "DAY_TRADE_FILL_AUCTION_RECHECK_ATTEMPTS", 3))) + _interval = max(0.0, float(getattr(settings, "DAY_TRADE_FILL_AUCTION_RECHECK_INTERVAL_SEC", 30.0))) + return _delay, _attempts, _interval + + +def _seconds_until_auction_recheck_impl(now: datetime) -> float: + """장 마감(15:30) + 재확인 대기(_auction_recheck_params_impl) 까지 남은 초 (지났으면 0).""" + _buf = _auction_recheck_params_impl()[0] + target = datetime.combine(now.date(), _KRX_MARKET_CLOSE) + timedelta(seconds=_buf) + return max(0.0, (target - now).total_seconds()) + + +def _apply_round_trip_streak_v2(round_trip_net: float) -> None: + """[R2-4] 전량 청산 시 왕복 순손익으로 당일 연패 판정 (trade_lock 안에서 호출). + + position_manager.update_pnl(is_full_close=True) 의 판정 규칙과 동일: + 손실 → +1, 이익 → 0 리셋, 0 → 중립. daily_pnl 은 호출부가 update_pnl(..., False) 로 더한다. + """ + if round_trip_net < 0: + day_trading_state.loss_streak_today += 1 + elif round_trip_net > 0: + day_trading_state.loss_streak_today = 0 + + +# [HL2-2 2026-10-09] 매도 0체결 복원 포지션의 '이미 기록된 승패' 표식. +# 전량 청산 접수 시 _finalize_exit_impl 이 당일 연패(update_pnl)·loss_tracker 를 기록하고, +# 그 매도가 0체결·부분 종결로 복원되면 기록은 되돌리지 않는다(§8.5-27). 복원 포지션이 다시 +# 전량 청산될 때 같은 왕복을 또 세면 거래 1건이 3연패/42일 밴 카운트를 2 올린다. +# 값: True(손실로 기록됨) / False(이익·비손실로 기록됨) / 키 없음(기록 안 됨). +# ⚠️ 인메모리 전용 — 재기동하면 사라져 재청산 시 다시 기록된다(보수적 방향). +_RESTORED_STREAK_MARK_KEY = "restored_streak_is_loss" +_RESTORED_LOSS_TRACKER_MARK_KEY = "restored_loss_tracker_is_loss" +# _finalize_exit_impl → _on_unfilled 로 넘기는 스냅샷 전용 키(포지션에는 남기지 않는다) +_FULL_EXIT_RECORD_KEY = "_full_exit_record" + +# [R6-1 2026-10-10] 당일 매수분 고아 표식. _detect_dt_orphans_impl 이 당일 창의 DT 원장 순체결이 +# 양수인 고아에 붙인다. 이 포지션은 실보유·타전략 차감·재시도 관리는 전일·고아(stale) 경로를 +# 그대로 타되, 실현손익은 **당일** daily_pnl·loss_streak_today 에 넣는다(§7 이 당일 집계 제외를 +# 허용하는 것은 '전일' 포지션뿐 — 당일 사고 판 손실이 -3% 한도·3연패 쿨다운을 우회하던 결함). +_ORPHAN_TODAY_KEY = "orphan_today" +# 당일 고아 청산 사유 접두사 — position_manager._STALE_EXIT_REASON_PREFIXES("DT 고아 잔고 청산")와 +# **겹치지 않아야** 재기동 원장 재구성(_rebuild_daily_state_from_rows)이 당일 손익에 포함한다. +_ORPHAN_TODAY_EXIT_REASON_PREFIX = "DT 당일 고아 잔고 청산" + + +def _is_orphan_today_position(pos) -> bool: + """[R6-1] 당일 매수분 고아(당일 손익 집계 대상)인가.""" + return bool((pos or {}).get(_ORPHAN_TODAY_KEY)) + + +def _should_record_outcome_impl(prev_mark, is_loss: bool) -> bool: + """같은 왕복에 대해 승패를 (다시) 기록해야 하는가. + + - 이전 기록 없음(None) → 기록 + - 이전과 같은 결과 → 이미 셌으므로 기록 안 함(이중 집계 방지) + - 이전=비손실, 이번=손실 → 기록(손실을 놓치지 않는 보수적 방향) + - 이전=손실, 이번=비손실 → 기록 안 함(이미 센 손실을 유지 — 보수적 방향) + """ + if prev_mark is None: + return True + return bool(is_loss) and prev_mark is False + + +def _read_outcome_mark_impl(src: dict | None, key: str): + """포지션 dict 의 승패 표식(True/False/None). bool 이 아니면 None(기록 안 됨으로 간주).""" + if not isinstance(src, dict): + return None + v = src.get(key) + return v if isinstance(v, bool) else None + + # [2026-10-01] 진입 시각 신뢰 구간 (KST 시각). DT 진입은 정규장 안에서만 일어나므로 # 이 밖의 entry_time 은 기준 불일치(예: 과거 UTC 저장 행 — KST 09:05 → UTC 00:05)로 본다. _ENTRY_TIME_VALID_FROM = (9, 0) @@ -247,6 +445,11 @@ async def check_and_exit_positions() -> list[dict]: # [S5] 미실현 손익도 포함하여 일일 손실 한도 판단 unrealized_pnl = 0.0 for _code, _pos in day_trading_state.positions.items(): + # [R1-4 2026-10-09] 전일(stale) 포지션의 미실현(오버나이트 갭 포함)은 당일 한도에 넣지 않는다. + # 실현손익도 당일에 넣지 않는 규칙(§7, count_daily_pnl=False)과 같은 기준이다. 넣으면 + # 갭 하락만으로 -3% 가 오발동해 당일 정상 포지션까지 force_close_all 로 청산된다. + if _is_stale_position(_pos): + continue _entry = _pos.get("entry_price", 0) _qty = _pos.get("quantity", 0) _cur = _pos.get("current_price", _entry) @@ -277,7 +480,7 @@ async def check_and_exit_positions() -> list[dict]: # [H4 2026-10-02] 1단계 — 손절선 선판정(현재가 1회 조회, 제한 동시성). # 분봉 전체 조회가 느려도 손절은 먼저 집행된다(§8.1-11 청산 우선). codes_snapshot = list(day_trading_state.positions.keys()) - fast = await _run_exit_checks_bounded_impl( + fast, _fast_failed = await _run_exit_checks_bounded_v2_impl( codes_snapshot, _fast_stop_check_impl, float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + 5.0, "손절 선판정", ) handled: set[str] = set() @@ -295,12 +498,19 @@ async def check_and_exit_positions() -> list[dict]: c for c in codes_snapshot if c not in handled and c in day_trading_state.positions ] - full = await _run_exit_checks_bounded_impl( + full, _full_failed = await _run_exit_checks_bounded_v2_impl( remaining_codes, _check_single_position, float(settings.DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC), "청산 판정", ) for code in remaining_codes: if full.get(code): exits.append(full[code]) + # [R3-3] 2단계 실패·판정 불가 종목은 연속 실패로 센다. + # [R4-3 2026-10-09] 1단계에서 '처리됨'으로 빠진 종목도 그 처리가 실패(손절 주문 KIS 거부·예외) + # 였으면 실패로 센다 — 예전에는 handled 종목이 _full_failed 에 없어 카운터가 0 으로 리셋됐다. + # (1단계 현재가 미확보 등 handled 가 아닌 종목은 2단계 결과가 우선한다.) + _update_exit_check_health_impl( + codes_snapshot, set(_full_failed) | (set(_fast_failed) & handled), "청산 판정", + ) return exits @@ -317,12 +527,14 @@ async def check_stops_only_v2() -> list[dict]: codes = list(day_trading_state.positions.keys()) if not codes: return exits - fast = await _run_exit_checks_bounded_impl( + fast, _fast_failed = await _run_exit_checks_bounded_v2_impl( codes, _fast_stop_check_impl, float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + 5.0, "ORB 구간 손절 판정", ) for res in fast.values(): if res and res[1]: exits.append(res[1]) + # [R3-3] ORB 구간은 선판정이 유일한 손절 판정 — 현재가 미확보도 실패로 센다. + _update_exit_check_health_impl(codes, _fast_failed, "ORB 구간 손절 판정") return exits @@ -332,9 +544,38 @@ async def _run_exit_checks_bounded_impl(codes, fn, timeout: float, label: str) - 주문~상태 반영은 _execute_exit_impl 이 shield 하므로, 상한 초과로 판정 코루틴이 취소돼도 이미 전송된 주문의 반영은 끝까지 진행된다. """ + out, _failed = await _run_exit_checks_bounded_v2_impl(codes, fn, timeout, label) + return out + + +# [R3-3 2026-10-09] 판정 실패 수집기 — 판정 단계마다 새 set 을 ContextVar 로 내려보낸다. +# gather 가 만든 자식 태스크는 컨텍스트를 복사하므로 같은 set 을 본다(동시 사이클 간 섞이지 않음). +_exit_failed_cv: contextvars.ContextVar = contextvars.ContextVar("dt_exit_failed_codes", default=None) +# [R3-3] 종목별 '판정 실패' 연속 사이클 수 / 이번 연속 구간에서 경보를 이미 보낸 종목 +_exit_check_failures: dict[str, int] = {} +_exit_stall_alerted: set[str] = set() +# [R3-3] 경보 기준 연속 사이클 수 기본값(config 미편입 — settings.DAY_TRADE_EXIT_CHECK_FAIL_ALERT_CYCLES 우선) +_EXIT_CHECK_FAIL_ALERT_CYCLES_DEFAULT = 4 + + +def _mark_exit_check_failed_impl(stock_code: str) -> None: + """[R3-3] 현재 판정 단계의 수집기에 '판정 실패(손절 미판정)'를 기록. 수집기 없으면 무시.""" + _c = _exit_failed_cv.get() + if _c is not None: + _c.add(stock_code) + + +async def _run_exit_checks_bounded_v2_impl(codes, fn, timeout: float, label: str) -> tuple[dict, set]: + """[R3-3] _run_exit_checks_bounded_impl + 판정 실패 종목 집합. ({code: 결과}, {실패 code}). + + 실패 = 포지션별 상한 초과·예외, 또는 판정 함수가 _mark_exit_check_failed_impl 로 알린 + '판정 불가'(폴백 손절 현재가 미확보 등). 예전에는 전부 삼키고 정상 반환해 스케줄러가 + '청산 체크 성공'으로 기록했다 — 손절이 멈춰도 경보가 없었다. + """ out: dict = {} + failed: set = set() if not codes: - return out + return out, failed sem = asyncio.Semaphore(max(1, int(settings.DAY_TRADE_EXIT_CHECK_CONCURRENCY))) async def _one(code: str) -> None: @@ -342,15 +583,114 @@ async def _run_exit_checks_bounded_impl(codes, fn, timeout: float, label: str) - try: out[code] = await asyncio.wait_for(fn(code), timeout=timeout) except asyncio.TimeoutError: + failed.add(code) logger.warning( f"[DayExit] ⏱️ {label} {timeout:.0f}초 초과 ({code}) — 이번 사이클 스킵, " f"다음 사이클 재판정" ) except Exception as e: + failed.add(code) logger.error(f"[DayExit] {label} 오류 ({code}): {e}", exc_info=True) - await asyncio.gather(*(_one(c) for c in codes)) - return out + _tok = _exit_failed_cv.set(failed) + try: + await asyncio.gather(*(_one(c) for c in codes)) + finally: + _exit_failed_cv.reset(_tok) + return out, failed + + +def _exit_check_fail_alert_cycles_impl() -> int: + try: + return max(1, int(getattr(settings, "DAY_TRADE_EXIT_CHECK_FAIL_ALERT_CYCLES", + _EXIT_CHECK_FAIL_ALERT_CYCLES_DEFAULT))) + except (TypeError, ValueError): + return _EXIT_CHECK_FAIL_ALERT_CYCLES_DEFAULT + + +# [R4-3 2026-10-09] 종목별 청산 매도 주문 '거부'(rejected) 연속 횟수 / 이번 연속 구간 경보 보낸 종목. +# 접수(accepted) 1회면 리셋. 전일(stale) 경로는 _stale_sell_failures 가 따로 센다(여기서 제외). +_exit_reject_streak: dict[str, int] = {} +_exit_reject_alerted: set[str] = set() + + +def _note_exit_order_rejected_impl( + stock_code: str, quantity: int, msg_cd, msg1, reason: str, +) -> None: + """[R4-3] DT 청산 주문 거부 1건 기록 — 판정 실패 수집기(헬스)에 넣고, N 회 연속이면 + 텔레그램(DT_EXIT_REJECT). 예전에는 logger.error 만 남고 헬스는 '성공'으로 리셋돼, + 손절선에 닿은 포지션의 시장가 매도가 계속 거부돼도 15:20 EOD 재시도 실패 전까지 경보가 없었다. + 매매 로직(재시도 주기·수량)은 바꾸지 않는다.""" + _mark_exit_check_failed_impl(stock_code) + n = _exit_reject_streak.get(stock_code, 0) + 1 + _exit_reject_streak[stock_code] = n + _limit = _exit_check_fail_alert_cycles_impl() + if n >= _limit and stock_code not in _exit_reject_alerted: + _exit_reject_alerted.add(stock_code) + _alert_once_v2( + "DT_EXIT_REJECT", f"{stock_code}:{time.monotonic():.0f}", + f"[데이트레이딩] {stock_code} 청산 매도 주문이 {n}회 연속 거부됨 — 손절·청산이 집행되지 " + f"않고 있습니다. 수량={int(quantity or 0)}주, msg_cd={msg_cd}, msg1={msg1}, 사유={reason}. " + f"계좌(주문가능수량·계좌 거부 코드)를 확인하고 필요하면 수동 청산하세요.", + ) + + +def _note_exit_order_accepted_impl(stock_code: str) -> None: + """[R4-3] 청산 주문 접수 → 거부 연속 횟수·경보 상태 해제.""" + _exit_reject_streak.pop(stock_code, None) + _exit_reject_alerted.discard(stock_code) + + +def _update_exit_check_health_impl(codes, failed: set, label: str) -> None: + """[R3-3] 이번 사이클 판정 결과로 종목별 연속 실패 수 갱신 + N 사이클 연속이면 텔레그램(DT_EXIT_STALL). + + 청산된(인메모리에 없는) 종목은 카운터를 지운다. 성공 1회면 0 으로 되돌리고 경보 상태도 푼다 + (다시 N 사이클 연속 실패하면 다시 경보). + """ + _limit = _exit_check_fail_alert_cycles_impl() + for code in list(codes or []): + if code not in day_trading_state.positions: + _exit_check_failures.pop(code, None) + _exit_stall_alerted.discard(code) + continue + if code not in failed: + _exit_check_failures.pop(code, None) + _exit_stall_alerted.discard(code) + continue + n = _exit_check_failures.get(code, 0) + 1 + _exit_check_failures[code] = n + if n >= _limit and code not in _exit_stall_alerted: + _exit_stall_alerted.add(code) + _qty = int((day_trading_state.positions.get(code) or {}).get("quantity", 0) or 0) + _alert_once_v2( + "DT_EXIT_STALL", f"{code}:{time.monotonic():.0f}", + f"[데이트레이딩] {code}({_qty}주) 청산 판정({label})이 {n}사이클 연속 실패 — 손절·트레일링·" + f"시간손절이 멈춘 상태일 수 있습니다(KIS 시세·분봉 조회 장애 추정). 계좌를 확인하고 " + f"필요하면 수동 청산하세요.", + ) + for code in [c for c in _exit_check_failures if c not in day_trading_state.positions]: + _exit_check_failures.pop(code, None) + _exit_stall_alerted.discard(code) + # [R4-3] 청산돼 인메모리에서 사라진 종목의 거부 카운터 정리 + for code in [c for c in _exit_reject_streak if c not in day_trading_state.positions]: + _exit_reject_streak.pop(code, None) + _exit_reject_alerted.discard(code) + + +def get_exit_check_health_v2() -> dict: + """[R3-3] 청산 판정 헬스 — {"failing": {code: 연속 실패 사이클}, "stalled": [경보 기준 이상 code], + "alert_cycles": N}. 스케줄러가 _record_dt_exit_result_v2(False) 판단에 쓸 수 있다.""" + _limit = _exit_check_fail_alert_cycles_impl() + failing = {c: n for c, n in _exit_check_failures.items() if c in day_trading_state.positions} + return { + "failing": failing, + "stalled": sorted(c for c, n in failing.items() if n >= _limit), + "alert_cycles": _limit, + # [R4-3] 종목별 청산 주문 거부 연속 횟수 + "order_rejects": { + c: n for c, n in _exit_reject_streak.items() if c in day_trading_state.positions + }, + } def _effective_stop_loss(pos: dict) -> float: @@ -374,6 +714,11 @@ async def _fast_stop_check_impl(stock_code: str) -> tuple[bool, dict | None]: # [H-B] 청산 주문 진행 중 → '처리됨'으로 보고 2단계 판정도 건너뛰게 한다 if day_trading_state.is_exit_in_progress(stock_code): return True, None + # [R1-4] 전일 포지션은 close_stale_positions 전용 — 일반 경로는 실보유·타전략 차감 없이 + # 행 수량 전량을 팔고 전일 손익을 당일 daily_pnl·연패에 넣는다(§7 금지). '처리됨'으로 넘긴다. + if _is_stale_position(pos): + _warn_stale_in_general_path(stock_code, "손절 선판정") + return True, None entry_price = float(pos.get("entry_price", 0) or 0) if entry_price <= 0 or int(pos.get("quantity", 0) or 0) <= 0: return False, None @@ -390,16 +735,21 @@ async def _fast_stop_check_impl(stock_code: str) -> tuple[bool, dict | None]: f"[DayExit] 손절 선판정 현재가 {settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC:.0f}초 초과 ({stock_code}) " f"— 분봉 판정으로 넘김" ) + _mark_exit_check_failed_impl(stock_code) # [R3-3] (2단계가 있으면 그쪽 결과가 우선) return False, None except Exception as e: logger.warning(f"[DayExit] 손절 선판정 현재가 조회 실패 ({stock_code}): {e} — 분봉 판정으로 넘김") + _mark_exit_check_failed_impl(stock_code) return False, None if current_price <= 0: + _mark_exit_check_failed_impl(stock_code) return False, None async with day_trading_state.trade_lock: if stock_code in day_trading_state.positions: day_trading_state.positions[stock_code]["current_price"] = current_price + _remember_price_impl(stock_code, current_price) # [R3-4] + _rest_price_seen[stock_code] = (current_price, _kst_now()) # [R1-9] 2단계 재사용 if current_price > stop_loss: return False, None @@ -413,11 +763,65 @@ async def _fast_stop_check_impl(stock_code: str) -> tuple[bool, dict | None]: result = await _execute_exit(stock_code, pos, current_price, reason, sell_pct=1.0) except Exception as e: logger.error(f"[DayExit] 손절 선판정 주문 실행 실패 ({stock_code}): {e}", exc_info=True) + _mark_exit_check_failed_impl(stock_code) # [R4-3] 손절 미집행 → 헬스에 실패로 result = None # 주문 실패여도 이번 사이클 분봉 판정에서 같은 손절을 재주문하지 않는다(다음 사이클 재시도). return True, result +# [R1-9 2026-10-09] 손절 선판정(1단계)이 REST 로 받은 현재가 {code: (가격, 관측 KST naive)}. +_rest_price_seen: dict[str, tuple[float, datetime]] = {} + +# [R3-4 2026-10-09] KIS 로 실제 관측한 현재가 {code: (가격, time.monotonic())} — 폴백 손절 대체가의 +# 나이 판정용. pos['current_price'] 는 영속화·복원되는 값이라 시각을 싣지 않고 여기 따로 둔다 +# (재기동 후엔 비어 있어 대체가 없음 = 판정 불가로 처리된다). +_price_seen_mono: dict[str, tuple[float, float]] = {} +# 대체가 최대 나이 기본값(초, config 미편입 — settings.DAY_TRADE_FALLBACK_PRICE_MAX_AGE_SEC 우선) +_DT_FALLBACK_PRICE_MAX_AGE_SEC_DEFAULT = 180.0 + + +def _remember_price_impl(stock_code: str, price: float) -> None: + """[R3-4] KIS 실관측 현재가를 monotonic 시각과 함께 기록.""" + try: + p = float(price) + except (TypeError, ValueError): + return + if p > 0: + _price_seen_mono[stock_code] = (p, time.monotonic()) + + +def _dt_fallback_price_max_age_impl() -> float: + try: + v = float(getattr(settings, "DAY_TRADE_FALLBACK_PRICE_MAX_AGE_SEC", _DT_FALLBACK_PRICE_MAX_AGE_SEC_DEFAULT)) + except (TypeError, ValueError): + v = _DT_FALLBACK_PRICE_MAX_AGE_SEC_DEFAULT + return v if v > 0 else _DT_FALLBACK_PRICE_MAX_AGE_SEC_DEFAULT + + +def _fresher_rest_price_impl(stock_code: str, candles) -> float | None: + """[R1-9] 2단계 판정가로 1단계 REST 현재가를 쓸지. 쓸 값 또는 None(분봉 종가 사용). + + 조건: (a) 같은 사이클 관측(나이 ≤ 선판정 상한 + 분봉 판정 상한) (b) 마지막 1분봉 라벨 + 60초 + 이후의 관측 — 라벨이 분 시작/종료 어느 규약이어도 그 봉이 끝난 뒤의 가격이라 분봉 종가보다 + 신선하다. 라벨을 알 수 없으면 (a) 만으로 판단한다. + """ + _rp = _rest_price_seen.get(stock_code) + if not _rp or _rp[0] <= 0: + return None + price, seen_at = _rp + _max_age = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + float(settings.DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC) + if (_kst_now() - seen_at).total_seconds() > _max_age: + return None + last_dt = None + for c in candles or []: + cdt = _candle_datetime(c) + if cdt is not None and (last_dt is None or cdt > last_dt): + last_dt = cdt + if last_dt is not None and seen_at < last_dt + timedelta(seconds=_POST_ENTRY_CANDLE_LAG_SEC): + return None + return float(price) + + async def _ensure_total_assets_for_exit_impl() -> float: """[L5] 청산 측 일일 손실한도 분모(총자산) 확보. @@ -475,6 +879,98 @@ async def _ensure_total_assets_for_exit_impl() -> float: return 0.0 +# [R3-1 2026-10-09] 청산 RSI 워밍업 — 진입 판정과 같은 시계열 기준으로 맞춘다. +# 진입(entry_service._fetch_candles_impl)은 전일 봉을 붙인 시계열로 RSI(14)를 계산하는데 +# 청산은 당일 봉만 써서, 당일 5분봉 15개 미만(약 10:10 이전)에는 rsi_wilder 가 None 이라 +# '과매수 청산'이 구조적으로 발동하지 않았고 이후에도 진입/청산 RSI 시드가 달랐다. +# 전일 봉은 장중에 바뀌지 않으므로 종목별로 하루 1회만 확보해 재사용한다(KIS 호출 최소화). +# 확보 실패·빈 결과면 _RSI_WARMUP_RETRY_SEC 마다 재시도하고, 그동안은 종전대로 당일 봉만 쓴다. +# 값 {종목: (당일 영업일자, 확보 시각 monotonic, 전일 1분봉 리스트)} +_rsi_warmup_prev_cache: dict[str, tuple[str, float, list]] = {} +_RSI_WARMUP_RETRY_SEC: float = 300.0 # 운영 상수(전략 파라미터 아님) — 빈 결과 재시도 간격 +_RSI_WARMUP_FETCH_TIMEOUT_SEC: float = 15.0 # 워밍업 조회 상한 — 포지션별 판정 상한(45s) 안에서 끝나게 + + +def _prev_session_candles_impl(candles: list, today_date: str) -> list: + """`today_date` 보다 이전 영업일자의 1분봉만 남긴다(영업일자 빈 봉 제외).""" + out = [] + for c in candles or []: + d = getattr(c, "date", "") or "" + if d and d < today_date: + out.append(c) + return out + + +async def _rsi_warmup_prev_candles_impl(stock_code: str, today_date: str) -> list: + """[R3-1] 청산 RSI 워밍업용 전일 1분봉. 실패해도 예외를 올리지 않고 [] 를 돌려준다. + + 순서: 모듈 캐시(당일 1회) → 진입 스캔 캐시(`entry_service._candle_cache`, 같은 시계열) + → `get_minute_chart_full_day_v2(include_prev_day=True)` 직접 조회. + """ + if not today_date: + return [] + now = time.monotonic() + cached = _rsi_warmup_prev_cache.get(stock_code) + if cached and cached[0] == today_date: + if cached[2]: + return cached[2] + if (now - cached[1]) < _RSI_WARMUP_RETRY_SEC: + return [] + + prev: list = [] + # 1) 진입 스캔 캐시 재사용 — 네트워크 없음 + try: + from app.services.daytrading import entry_service as _ens + ent = _ens._candle_cache.get(stock_code) + if ent: + prev = _prev_session_candles_impl(ent[1], today_date) + except Exception as e: + logger.warning(f"[DayExit] 진입 분봉 캐시 참조 실패 ({stock_code}): {e}") + prev = [] + + # 2) 직접 조회 (진입과 같은 API·같은 옵션) + if not prev: + try: + raw = await asyncio.wait_for( + korea_investment_api.get_minute_chart_full_day_v2( + stock_code, include_prev_day=True + ), + timeout=_RSI_WARMUP_FETCH_TIMEOUT_SEC, + ) + prev = _prev_session_candles_impl(parse_minute_candles(raw or []), today_date) + except Exception as e: + warn_throttled( + logger, f"dt_exit:rsi_warmup:{stock_code}", + f"[DayExit] 청산 RSI 워밍업 조회 실패 ({stock_code}): {e!r} " + f"— 당일 봉만으로 RSI 판정({_RSI_WARMUP_RETRY_SEC:.0f}초 후 재시도)", + ) + prev = [] + + # 당일 외 항목 정리(무한 증식 방지) + for k in [k for k, v in _rsi_warmup_prev_cache.items() if v[0] != today_date]: + _rsi_warmup_prev_cache.pop(k, None) + _rsi_warmup_prev_cache[stock_code] = (today_date, now, prev) + if not prev: + warn_throttled( + logger, f"dt_exit:rsi_warmup_empty:{stock_code}", + f"[DayExit] 청산 RSI 워밍업 전일 봉 0개 ({stock_code}) — 당일 5분봉 15개 미만 구간은 " + f"과매수 청산(RSI≥{float(settings.DAY_TRADE_RSI_EXIT_OVERBOUGHT):g}) 판정 불가", + ) + return prev + + +def _exit_signal_candles_5m_impl(today_candles: list, prev_candles: list) -> list: + """청산 판정용 5분봉 = 전일 워밍업 + 당일 봉 (진입 판정 시계열과 같은 구성). + + `check_day_trade_exit` 에서 이 시계열이 쓰이는 곳은 `_rsi`(워밍업 의도)와 + `_atr`(내부에서 `_last_session_candles` 로 당일 세션만 사용 — 갭 TR 미포함)뿐이므로 + ATR·트레일링은 당일 봉 기준 그대로다. 현재가·고점은 호출부가 당일 봉으로 따로 구한다. + """ + if not prev_candles: + return aggregate_to_5min(today_candles) + return aggregate_to_5min(list(prev_candles) + list(today_candles)) + + async def _check_single_position(stock_code: str) -> dict | None: """단일 포지션의 청산 조건 체크. @@ -493,6 +989,10 @@ async def _check_single_position(stock_code: str) -> dict | None: # [H-B] 청산 주문 진행 중이면 판정하지 않는다(인메모리 수량이 아직 반영 전) if day_trading_state.is_exit_in_progress(stock_code): return None + # [R1-4] 전일 포지션은 close_stale_positions 전용(실보유·타전략 차감·당일 손익 제외) + if _is_stale_position(pos): + _warn_stale_in_general_path(stock_code, "분봉 판정") + return None entry_price = pos.get("entry_price", 0) # [M7] stop_loss / target_price 는 None 뿐 아니라 0 으로 저장될 수 있다. @@ -531,6 +1031,11 @@ async def _check_single_position(stock_code: str) -> dict | None: try: current_price = float(candles_5m[-1].close) + # [R1-9] 1단계 REST 현재가가 마지막 1분봉보다 확실히 뒤의 관측이면 그 값을 쓴다 + # (분봉은 1분 버킷 캐시라 최대 1분 묵을 수 있다 — 낡은 종가로 신선한 현재가를 덮어쓰지 않게). + _rest_px = _fresher_rest_price_impl(stock_code, candles) + if _rest_px is not None: + current_price = _rest_px # highest_price 갱신 — 진입 이후에 시작한 1분봉 고가만 반영 (30초 주기 보완). # [2026-10-01] 예전 `candles_5m[-3:]` 는 진입 전 봉 고가가 섞여 트레일링 고점을 오염시켰다. @@ -549,9 +1054,16 @@ async def _check_single_position(stock_code: str) -> dict | None: day_trading_state.positions[stock_code]["highest_price"] = new_highest # [S5] 미실현 손익 계산용 현재가 갱신 day_trading_state.positions[stock_code]["current_price"] = current_price + _remember_price_impl(stock_code, current_price) # [R3-4] if new_highest > highest_price: highest_price = new_highest + # [R3-1] RSI 판정 시계열만 진입과 같은 워밍업 구성으로 맞춘다(임계값 불변). + # 현재가·고점은 위에서 당일 봉으로 이미 확정했고, ATR 은 _atr 이 당일 세션만 쓴다. + _today_date = (getattr(candles[-1], "date", "") or "") if candles else "" + _prev_warm = await _rsi_warmup_prev_candles_impl(stock_code, _today_date) + candles_5m_signal = _exit_signal_candles_5m_impl(candles, _prev_warm) + # 청산 신호 체크 current_time = _kst_now().time() exit_signal = check_day_trade_exit( @@ -560,7 +1072,7 @@ async def _check_single_position(stock_code: str) -> dict | None: stop_loss=stop_loss, target_price=target_price, highest_since_entry=highest_price, - candles_5min=candles_5m, + candles_5min=candles_5m_signal, current_time=current_time, entry_time=entry_time, ) @@ -614,17 +1126,34 @@ async def _check_fallback_stop_loss( current_price = float(resp.get("output", {}).get("stck_prpr", 0) or 0) if resp else 0.0 except Exception as e: logger.warning(f"[DayExit] 폴백 손절 현재가 조회 실패 ({stock_code}): {e}") - if current_price <= 0: - current_price = float(pos.get("current_price", 0) or 0) - if current_price <= 0: - logger.error( - f"[DayExit] 폴백 손절 현재가 확보 실패 ({stock_code}) — 손절 미적용 상태, 수동 확인 필요" - ) - return None + _fresh_from_kis = current_price > 0 + if not _fresh_from_kis: + # [R3-4] 대체가는 나이 상한 안의 최근 관측가만 쓴다. 예전에는 시각 없는 pos['current_price'] + # 를 써서, 수십 분 전 가격이 손절가보다 높으면 '미도달'로 조용히 통과했다. + _max_age = _dt_fallback_price_max_age_impl() + _cached = _price_seen_mono.get(stock_code) + _age = (time.monotonic() - _cached[1]) if _cached else None + if _cached and _cached[0] > 0 and _age is not None and 0 <= _age <= _max_age: + current_price = float(_cached[0]) + logger.warning( + f"[DayExit] 폴백 손절 대체가 사용 ({stock_code}): {current_price:.0f} (나이 {_age:.0f}초 ≤ {_max_age:.0f}초)" + ) + else: + # 판정 불가 — '손절 미도달'과 구분한다(error + 연속 실패 집계 → N 사이클이면 텔레그램). + _mark_exit_check_failed_impl(stock_code) + _error_throttled( + f"dt_exit:fallback_no_price:{stock_code}", + f"[DayExit] 🚨 폴백 손절 판정 불가 ({stock_code}) — KIS 현재가 실패, 대체가 " + + (f"나이 {_age:.0f}초 > {_max_age:.0f}초" if _age is not None else "없음") + + " — 손절 미적용 상태, 수동 확인 필요", + ) + return None async with day_trading_state.trade_lock: if stock_code in day_trading_state.positions: day_trading_state.positions[stock_code]["current_price"] = current_price + if _fresh_from_kis: + _remember_price_impl(stock_code, current_price) if current_price > stop_loss: return None @@ -731,6 +1260,11 @@ async def _execute_exit_impl( f"[DayExit] {stock_code} 판정 이후 포지션이 청산/교체됨 — 주문하지 않는다 ({reason})" ) return None + elif _is_stale_position(pos): + # [R1-4] 공통 종단 가드 — 전일 포지션은 is_stale=True 경로(close_stale_positions)만 판다. + # 일반 경로는 실보유·타전략 차감 없이 행 수량 전량을 팔고 전일 손익을 당일에 넣는다. + _warn_stale_in_general_path(stock_code, f"일반 청산({reason})") + return None day_trading_state.exits_in_progress.add(stock_code) _tok = object() _exit_tokens[stock_code] = _tok @@ -755,6 +1289,15 @@ def _is_stale_position(pos: dict) -> bool: return hasattr(et, "date") and et.date() < _kst_now().date() +def _warn_stale_in_general_path(stock_code: str, label: str) -> None: + """[R1-4] 일반 청산 경로가 전일 포지션을 만났다 — 건너뛰고 stale 경로에 맡긴다(로그 스로틀).""" + warn_throttled( + logger, f"dt_exit:stale_in_general:{stock_code}", + f"[DayExit] {stock_code} 전일 미청산 포지션 — {label}·EOD·일일한도 경로에서 제외, " + f"close_stale_positions(실보유·타전략 차감, 당일 손익 제외)만 청산한다", + ) + + def _release_exit_in_progress(stock_code: str, delay: float = 0.0, token=None) -> None: """[H-B] 청산 진행 중 표식 해제. delay>0 이면 그만큼 뒤에 해제(결과 불명 보류). @@ -814,7 +1357,8 @@ _RATE_LIMIT_MSG_CDS = frozenset({"EGW00201"}) # safe_place_order 가 분산락을 못 잡으면 주문을 보내지 않고 이 msg1 로 돌려준다(전송 전). _ORDER_LOCK_BUSY_MSG = "order_lock_busy" # [H-2] 결과 불명 주문이 아직 확인되지 않은 종목 -# {(날짜, code): {"qty": 주문수량, "since": datetime, "total_qty": 주문 시 DT 보유}} +# {(날짜, code): {"qty": 주문수량, "since": datetime, "total_qty": 주문 시 DT 보유, +# "reason"/"price"/"sell_pct": 원 주문의 청산 사유·판정가·매도비율 (R4-2)}} # [L-1 r5] 키에 날짜를 넣고 날짜가 바뀌면 정리한다(_prune_exit_state_for_today). _unresolved_exit_unknown: dict[tuple[str, str], dict] = {} # [M-3 r5] 불명 확인용 조회(잔고·비DT 수량) 연속 실패 횟수 {(날짜, code): n}, 경보 보낸 키 @@ -994,7 +1538,14 @@ async def _resolve_unknown_exit_v2_impl( (같은 수량·시각의 스윙 매도를 DT 접수로 오인하지 않게). DT 매도가능수량 = max(0, min(주문가능, 보유) − 비-DT active 수량) """ - h = await _fetch_kis_holdings_v2_impl() + h, _complete = await _fetch_kis_holdings_v3_impl() + if h is not None and _holding_unknown_v2(h, _complete, stock_code): + # [R3-1] 응답에 없는데 첫 페이지가 가득 참 → 0주(=접수)로 확정하지 않는다(판단 불가·보류). + logger.warning( + f"[DayExit] {stock_code} 결과 불명 매도 판정 보류 — KIS 잔고 첫 페이지 가득 참·종목 미발견" + f"(다음 페이지에 있을 수 있음)" + ) + return "lookup_failed", None, 0 nd = await _fetch_non_dt_active_qty_impl(stock_code) if h is not None else None if h is None or nd is None: return "lookup_failed", None, 0 @@ -1087,9 +1638,28 @@ async def _place_and_finalize_exit_impl( if _st == "accepted": _unresolved_exit_unknown.pop(_ukey, None) _last_exit_order_outcome[stock_code] = "accepted" + if not is_stale: + _note_exit_order_accepted_impl(stock_code) # [R4-3] _pq = min(int(_prev["qty"]), int(total_qty)) + # [R4-2 2026-10-09] 확정되는 것은 **원 주문**이다 — 이번 사이클의 판정(사유·가격)이 + # 아니라 등록 때 저장한 원 사유·가격·매도비율로 반영한다. 이번 사유로 확정하면 + # 손절이 시간손절로 기록돼 loss_tracker 에서 빠지거나(§5.1) 그 반대가 되고, + # TradeHistory exit_reason·(ODNO 미발견 시) 순손익도 틀린다. + # 옛 엔트리(키 없음)는 이번 호출 값으로 폴백(구 동작). + _o_reason = _prev.get("reason") or reason + try: + _o_price = float(_prev.get("price") or 0) or price + except (TypeError, ValueError): + logger.warning(f"[DayExit] {stock_code} 불명 주문 원 가격 파싱 실패 — 이번 판정가 사용") + _o_price = price + _o_sell_pct = _prev.get("sell_pct", sell_pct) + if _o_reason != reason: + logger.warning( + f"[DayExit] {stock_code} 직전 결과 불명 매도 접수 확정 — 원 사유 '{_o_reason}'·" + f"가격 {_o_price:,.0f} 로 반영(이번 판정 '{reason}'·{price:,.0f} 아님)" + ) return await _finalize_after_accept_impl( - stock_code, pos, price, reason, sell_pct if _pq < total_qty else 1.0, + stock_code, pos, _o_price, _o_reason, _o_sell_pct if _pq < total_qty else 1.0, _resp, _pq, total_qty, _pq >= total_qty, is_stale, ) # not_accepted — 재매도 수량을 DT 매도가능수량으로 상한(스윙 물량 보호) @@ -1101,6 +1671,11 @@ async def _place_and_finalize_exit_impl( f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + DT 매도가능 0 — 재매도하지 않는다" f"(실보유·ActiveTrade 수동 확인)", ) + if not is_stale: + # [R4-3] 매도 불가 상태도 청산 미집행 — 헬스·연속 거부 경보에 반영 + _note_exit_order_rejected_impl( + stock_code, quantity, "DT_SELLABLE_ZERO", "직전 결과 불명 + DT 매도가능 0", reason, + ) return None if _dt_sellable < quantity: logger.warning( @@ -1145,6 +1720,8 @@ async def _place_and_finalize_exit_impl( # 아직 반영 전일 수 있다(주문가능수량 갱신 지연) → 기록만 하고 보류. 재시도 직전에 다시 확인. _unresolved_exit_unknown[_ukey] = { "qty": quantity, "since": _since, "total_qty": total_qty, + # [R4-2] 접수 확정 시 원 주문 기준으로 반영하기 위한 원 사유·가격·매도비율 + "reason": reason, "price": price, "sell_pct": sell_pct, } if _st == "lookup_failed": _note_unknown_lookup_failure(stock_code) @@ -1157,8 +1734,15 @@ async def _place_and_finalize_exit_impl( f"[DayExit] 매도 주문 실패 ({stock_code}): msg_cd={_r.get('msg_cd')} " f"{_r.get('msg1', 'unknown')}" ) + if not is_stale: + # [R4-3] 거부 연속 횟수 → 헬스 실패 + N 회째 텔레그램. 전일 경로는 _stale_sell_failures 담당. + _note_exit_order_rejected_impl( + stock_code, quantity, _r.get("msg_cd"), _r.get("msg1"), reason, + ) return None _last_exit_order_outcome[stock_code] = "accepted" + if not is_stale: + _note_exit_order_accepted_impl(stock_code) # [R4-3] return await _finalize_after_accept_impl( stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty, is_full_exit, is_stale, @@ -1245,10 +1829,12 @@ async def _finalize_exit_impl( count_daily_pnl=False 이면 당일 손익/연속손실 집계를 건너뛰고 전일 손익 누적값에만 더한다(전일 포지션 강제청산 경로). """ - global _stale_realized_pnl entry_price = pos.get("entry_price", 0) stock_name = pos.get("stock_name", stock_code) order_id = order_resp.get("output", {}).get("ODNO", "") + # [R6-1] 당일 매수분 고아는 stale 경로(count_daily_pnl=False)로 들어와도 당일 손익·연패에 넣는다. + # 인메모리·closed_today·체결 0 복원 처리는 원래 플래그(count_daily_pnl)를 그대로 따른다. + _count_today = bool(count_daily_pnl) or _is_orphan_today_position(pos) # [2026-10-01] 순손익(수수료·거래세 차감)으로 기록·집계한다. # 일일 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT)·당일 연패·loss_tracker 가 모두 이 값을 본다 # → 비용만큼 한도가 **약간 더 일찍** 발동한다(보수적 방향, 의도). 손익분기(gross≈0) @@ -1260,9 +1846,36 @@ async def _finalize_exit_impl( profit_loss_rate = _pnl["net_profit_loss_rate"] if entry_price > 0 else 0.0 # 인메모리 상태 업데이트 (DB 직접 접근 금지) + _recent_dt_sell_finalized[stock_code] = time.monotonic() + # [R2-4 2026-10-09] 왕복(분할 익절 포함) 순손익. 분할 매도 레그의 순손익을 포지션에 + # 누적(realized_net)해 두고, 전량 청산 때 마지막 레그와 합쳐 승패(당일 연패·loss_tracker)를 + # 판정한다. 예전에는 마지막 레그만 봐 +1.5R 분할 후 잔량 -0.1% 청산이 '손실'로 셌다. + # ⚠️ 인메모리 전용 — 재기동하면 누적이 사라져 마지막 레그 기준(구 동작)으로 돌아간다. + _prior_realized = 0.0 + # [R4-1 2026-10-09] 분할 레그가 0체결로 종결되면 _on_unfilled 가 되돌릴 수 있도록 + # 분할 전 목표가·partial_sold 를 보관한다(아래에서 positions[code] 를 inf/True 로 바꾸기 전). + _pre_partial_state: dict | None = None async with day_trading_state.trade_lock: + _mem_pos = day_trading_state.positions.get(stock_code) + if not is_full_exit and _mem_pos is not None: + _pre_partial_state = { + "target_price": _mem_pos.get("target_price"), + "partial_sold": bool(_mem_pos.get("partial_sold", False)), + } + _src = _mem_pos if _mem_pos is not None else (pos or {}) + try: + _prior_realized = float(_src.get("realized_net", 0.0) or 0.0) + except (TypeError, ValueError): + logger.warning(f"[DayExit] {stock_code} realized_net 파싱 실패 — 마지막 레그 기준 판정") + _prior_realized = 0.0 + # [HL2-2] 0체결 복원 포지션이면 같은 왕복의 승패가 이미 기록돼 있다(표식). + _prev_streak_mark = _read_outcome_mark_impl(_src, _RESTORED_STREAK_MARK_KEY) + _prev_lt_mark = _read_outcome_mark_impl(_src, _RESTORED_LOSS_TRACKER_MARK_KEY) + _streak_mark = _prev_streak_mark if is_full_exit: day_trading_state.remove_position(stock_code) + # [2026-10-08] DT 전용 청산 집계(엔진 카드 '청산 N') — 인메모리에 없던 전일·고아 포지션 포함 + day_trading_state.dt_exited_today.add(stock_code) if not count_daily_pnl: # [M10] DB에서만 발견된 전일 포지션은 인메모리에 없어 remove_position()의 # closed_today 추가가 일어나지 않는다 → 강제청산한 종목이 같은 날 @@ -1274,13 +1887,34 @@ async def _finalize_exit_impl( day_trading_state.positions[stock_code]["quantity"] = remaining day_trading_state.positions[stock_code]["target_price"] = float("inf") day_trading_state.positions[stock_code]["partial_sold"] = True - if count_daily_pnl: - day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit) + day_trading_state.positions[stock_code]["realized_net"] = _prior_realized + profit_loss + if _count_today: + if not count_daily_pnl: + logger.warning( + f"[DayExit] 당일 매수분 고아 청산 실현손익은 당일 집계에 포함: {stock_code} " + f"{profit_loss:+.0f}원 (일일 손실한도·당일 연패 반영)" + ) + if is_full_exit and (_prior_realized != 0.0 or _prev_streak_mark is not None): + # daily_pnl 은 이 레그만 더하고, 연패는 왕복 순손익으로 판정한다. + day_trading_state.update_pnl(profit_loss, is_full_close=False) + _rt_streak = _prior_realized + profit_loss + if _rt_streak != 0.0 and _should_record_outcome_impl(_prev_streak_mark, _rt_streak < 0): + _apply_round_trip_streak_v2(_rt_streak) + _streak_mark = _rt_streak < 0 + elif _rt_streak != 0.0: + logger.info( + f"[DayExit] {stock_code} 매도 미체결 복원 포지션 재청산 — 당일 연패는 이미 " + f"기록됨(이전 손실={_prev_streak_mark}) → 왕복 {_rt_streak:+.0f}원 재집계 생략" + ) + else: + day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit) + if is_full_exit and profit_loss != 0.0: + _streak_mark = profit_loss < 0 else: - _stale_realized_pnl += profit_loss + _stale_now = _add_stale_realized_pnl(profit_loss) logger.warning( f"[DayExit] 전일 포지션 실현손익은 당일 집계 제외: {stock_code} " - f"{profit_loss:+.0f}원 (전일 누적={_stale_realized_pnl:+.0f}원)" + f"{profit_loss:+.0f}원 (오늘 처리한 전일·고아 누적={_stale_now:+.0f}원)" ) # [M-2 2026-10-02] 인메모리 반영이 끝났으므로 청산 진행 중 표식을 **지금** 해제한다. @@ -1306,15 +1940,79 @@ async def _finalize_exit_impl( "profit_loss_rate": profit_loss_rate, "strategy_type": "daytrading", }), stock_code, "TradeHistory 큐잉") + # [R2-1 2026-10-09] loss_tracker 기록 시점 결정. + # 15:20~15:30 은 KRX 종가 단일가 구간이라 그때 낸 시장가 매도는 15:30 에 체결된다. + # 주문 직후 체결 확인(수 초)은 체결 0·미종결만 보고 끝나므로, 이 구간 매도는 15:30 이후 + # 재확인한 **실제 체결가 기준 왕복 순손익**으로 loss_tracker 승패를 기록한다(_on_settled). + # 재확인이 실패하면 지금 값(신호가 기준)으로 기록한다 — 구 동작으로 폴백. + _record_loss_now = False + _on_settled = None + _auction = False + if is_full_exit and not is_time_stop_exit_reason(reason): + _auction = bool( + settings.DAY_TRADE_FILL_CHECK_ENABLED and order_id + and _in_closing_auction_impl(_kst_now()) + ) + if _auction: + async def _on_settled(leg_net): + if leg_net is None: + logger.warning( + f"[DayExit] {stock_code} 종가 단일가 매도 전량 체결 미확인(체결 0·부분) — " + f"loss_tracker 기록 보류(잔량이 청산될 때 기록)" + ) + return + _rt = _prior_realized + float(leg_net) + if not _should_record_outcome_impl(_prev_lt_mark, _rt < 0): + logger.info( + f"[DayExit] {stock_code} 매도 미체결 복원 포지션 재청산 — loss_tracker 는 이미 " + f"기록됨(이전 손실={_prev_lt_mark}) → 왕복 {_rt:+.0f}원 재기록 생략" + ) + return + logger.info( + f"[DayExit] {stock_code} 종가 단일가 체결 확인 후 연속손실 기록: " + f"왕복 순손익={_rt:+.0f}원 (신호가 기준 {_prior_realized + profit_loss:+.0f}원)" + ) + + async def _rec(): + await loss_tracker.record_trade_result_safe(stock_code, _rt < 0) + await loss_tracker.save_to_db(stock_code=stock_code) + + await _bounded_finalize_step(_rec(), stock_code, "loss_tracker 기록(체결 확인 후)") + else: + _record_loss_now = True # [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 확인되면 원장·당일 손익 보정. + _scheduled = None + _snap_pos = dict(pos) + if _pre_partial_state is not None: + _snap_pos[_PRE_PARTIAL_STATE_KEY] = _pre_partial_state + # [HL2-2] 전량 청산 접수 때 기록한(할) 승패와 그 시점 왕복 누적을 스냅샷에 싣는다. + # 0체결·부분 종결로 복원되면 _on_unfilled 가 이 값으로 복원 포지션에 표식·realized_net 을 남긴다. + _lt_will_record = False + _lt_mark = _prev_lt_mark + if _record_loss_now: + _lt_is_loss = (_prior_realized + profit_loss) < 0 + _lt_will_record = _should_record_outcome_impl(_prev_lt_mark, _lt_is_loss) + if _lt_will_record: + _lt_mark = _lt_is_loss + if is_full_exit: + _snap_pos[_FULL_EXIT_RECORD_KEY] = { + "streak": _streak_mark if _count_today else None, + "loss_tracker": _lt_mark, + "prior_realized": _prior_realized, + } try: - _schedule_sell_fill_reconcile( - stock_code=stock_code, pos=dict(pos), order_id=order_id, signal_price=price, + _scheduled = _schedule_sell_fill_reconcile_v2( + stock_code=stock_code, pos=_snap_pos, order_id=order_id, signal_price=price, quantity=quantity, entry_price=entry_price, recorded_net=profit_loss, - count_daily_pnl=count_daily_pnl, + count_daily_pnl=count_daily_pnl, is_partial=not is_full_exit, + on_settled=_on_settled, closing_auction=_auction, ) except Exception as e: logger.warning(f"[DayExit] {stock_code} 체결가 확인 예약 실패: {e}") + if _on_settled is not None and _scheduled is None: + # 재확인 예약 실패 → 지연 기록 주체가 없다. 지금 값으로 기록(구 동작). + logger.warning(f"[DayExit] {stock_code} 종가 단일가 재확인 예약 실패 — 신호가 기준으로 연속손실 기록") + _record_loss_now = True if is_full_exit: await _bounded_finalize_step( enqueue_close_active_trade(stock_code, strategy_name="day_trade"), @@ -1362,15 +2060,29 @@ async def _finalize_exit_impl( f"순손익={profit_loss:+.0f}원 — {settings.CONSECUTIVE_LOSS_BAN_COUNT}연패/" f"{settings.CONSECUTIVE_LOSS_BAN_DAYS}일 밴 카운트 변화 없음" ) - else: - is_loss = profit_loss < 0 + elif _record_loss_now: + # [R2-4] 왕복 순손익(분할 익절 레그 포함)으로 승패 판정 + _round_trip = _prior_realized + profit_loss + is_loss = _round_trip < 0 + if _prior_realized != 0.0: + logger.info( + f"[DayExit] {stock_code} 왕복 순손익 {_round_trip:+.0f}원 " + f"(분할 {_prior_realized:+.0f} + 잔량 {profit_loss:+.0f}) 기준 승패 판정" + ) async def _record_loss(): # record_trade_result() 후 반드시 save_to_db() (§5.1) await loss_tracker.record_trade_result_safe(stock_code, is_loss) await loss_tracker.save_to_db(stock_code=stock_code) - await _bounded_finalize_step(_record_loss(), stock_code, "loss_tracker 기록") + # [HL2-2] 0체결 복원 포지션 재청산이면 같은 왕복을 다시 세지 않는다. + if _should_record_outcome_impl(_prev_lt_mark, is_loss): + await _bounded_finalize_step(_record_loss(), stock_code, "loss_tracker 기록") + else: + logger.info( + f"[DayExit] {stock_code} 매도 미체결 복원 포지션 재청산 — loss_tracker 는 이미 " + f"기록됨(이전 손실={_prev_lt_mark}) → 왕복 {_round_trip:+.0f}원 재기록 생략" + ) # 포트폴리오 화면에서 즉시 제거/차감 from app.services.portfolio_cache_bridge import reflect_sell @@ -1418,9 +2130,30 @@ async def _bounded_finalize_step(coro, stock_code: str, label: str) -> None: def _schedule_sell_fill_reconcile( stock_code: str, pos: dict, order_id: str, signal_price: float, quantity: int, entry_price: float, recorded_net: float, count_daily_pnl: bool, +): + """매도 실제 체결가 확인 예약 — 구 시그니처 유지용 위임(_schedule_sell_fill_reconcile_v2).""" + return _schedule_sell_fill_reconcile_v2( + stock_code=stock_code, pos=pos, order_id=order_id, signal_price=signal_price, + quantity=quantity, entry_price=entry_price, recorded_net=recorded_net, + count_daily_pnl=count_daily_pnl, + ) + + +def _schedule_sell_fill_reconcile_v2( + stock_code: str, pos: dict, order_id: str, signal_price: float, quantity: int, + entry_price: float, recorded_net: float, count_daily_pnl: bool, + is_partial: bool = False, on_settled=None, closing_auction: bool = False, ): """매도 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단). + [R2-1/R2-4 2026-10-09] v2 추가 인자: + - is_partial: 분할 매도 레그면 체결가 보정 차이(delta)를 잔량 포지션의 realized_net + (왕복 순손익 누적)에도 반영한다. + - on_settled(leg_net | None): 이 레그의 **확정** 순손익을 한 번 넘긴다(전량 체결 확인 → + 실제 체결가 기준, 확인 불가 → 신호가 기준 recorded_net, 체결 0·부분 → None). + - closing_auction: 15:20~15:30 종가 단일가 주문. 즉시 확인이 종결이 아니면 15:30 이후 + (DAY_TRADE_FILL_AUCTION_RECHECK_*) 다시 확인한다. + 확인되면: - TradeHistory(order_id) 의 체결가·비용·순손익·수익률·슬리피지(%)를 db_worker 로 갱신 - 기록했던 순손익과의 차이를 당일 daily_pnl 에 보정(전일 stale 청산은 _stale_realized_pnl — @@ -1429,46 +2162,766 @@ def _schedule_sell_fill_reconcile( """ from app.core.timeutil import now_kst as _now_kst _order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회 + _settled = [False] + # [R6-1] 당일 매수분 고아: stale 경로(count_daily_pnl=False — 인메모리 복원 없이 재청산 대기)를 + # 유지하되 손익 보정은 당일 daily_pnl 로 보낸다(_finalize_exit_impl 의 당일 집계와 대칭). + _orphan_today = (not count_daily_pnl) and _is_orphan_today_position(pos) + + async def _settle(leg_net) -> None: + """on_settled 를 최대 1회 호출 (예외는 warning — 흐름 비차단).""" + if _settled[0] or on_settled is None: + _settled[0] = True + return + _settled[0] = True + try: + await on_settled(leg_net) + except Exception as e: + logger.warning(f"[DayExit] {stock_code} 매도 확정 콜백 실패: {type(e).__name__}: {e}", exc_info=True) + + def _adjust_partial_realized_impl(delta: float) -> None: + """분할 레그 체결가 보정분을 잔량 포지션의 왕복 누적(realized_net)에 반영 (trade_lock 안).""" + if not is_partial or not delta: + return + _cur = day_trading_state.positions.get(stock_code) + if _cur is not None and _cur.get("partial_sold"): + _cur["realized_net"] = float(_cur.get("realized_net", 0.0) or 0.0) + float(delta) async def _on_fill(filled_qty: int, avg_price: float) -> None: - global _stale_realized_pnl if filled_qty < quantity: logger.warning( - f"[DayExit] 매도 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 " - f"order_id={order_id} — 인메모리 수량은 주문 수량 기준으로 반영돼 있다(수동 확인 필요)" + f"[DayExit] 매도 부분체결 확인(미종결): {stock_code} {filled_qty}/{quantity}주 " + f"order_id={order_id} — 체결분은 체결가, 미체결 {int(quantity) - int(filled_qty)}주는 " + f"신호가 기준 추정으로 손익 반영. 인메모리 수량은 주문 수량 기준(수동 확인 필요)" ) - new = dt_net_pnl(entry_price, avg_price, quantity, pos) + # [H-R1-2 2026-10-09] 미종결 부분체결이면 미체결분을 체결가로 계상하지 않는다 + # (_sell_fill_pnl_impl — 체결분 실측 + 미체결분 신호가 추정, 거래대금·체결수량은 체결분만). + new = _sell_fill_pnl_impl(entry_price, avg_price, signal_price, filled_qty, quantity, pos) delta = new["net_profit_loss"] - recorded_net slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0 from decimal import Decimal from app.services.db_worker import enqueue_update_trade_history_by_order_id - await enqueue_update_trade_history_by_order_id(order_id, { + _th_upd = { # 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다 "order_date": _order_date, "order_type": "SELL", "strategy_type": "daytrading", "executed_price": Decimal(str(round(avg_price, 2))), - "total_amount": Decimal(str(round(avg_price * quantity, 2))), + "total_amount": Decimal(str(round(new["total_amount"], 2))), "commission": Decimal(str(new["total_cost"])), "net_profit_loss": Decimal(str(round(new["net_profit_loss"], 2))), "profit_loss_rate": Decimal(str(round(new["net_profit_loss_rate"], 4))) if entry_price > 0 else None, "slippage": Decimal(str(round(slippage_pct, 4))), - }) + } + if new["is_partial"]: + _th_upd["executed_quantity"] = int(new["executed_quantity"]) + await enqueue_update_trade_history_by_order_id(order_id, _th_upd) if delta: if count_daily_pnl: + async with day_trading_state.trade_lock: + day_trading_state.adjust_realized_pnl(delta) + _adjust_partial_realized_impl(delta) + await day_trading_state.save_daily_state() + elif _orphan_today: async with day_trading_state.trade_lock: day_trading_state.adjust_realized_pnl(delta) await day_trading_state.save_daily_state() else: - _stale_realized_pnl += delta + _add_stale_realized_pnl(delta) logger.info( f"[DayExit] 매도 체결가 확인: {stock_code} 신호가={signal_price:,.0f} " f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%) " f"순손익 {recorded_net:+.0f}→{new['net_profit_loss']:+.0f}원 " - f"({'당일' if count_daily_pnl else '전일'} 손익 보정 {delta:+.0f}원)" + f"({'당일' if (count_daily_pnl or _orphan_today) else '전일'} 손익 보정 {delta:+.0f}원)" + ) + await _settle(new["net_profit_loss"] if filled_qty >= quantity else None) + + async def _on_unfilled(filled_qty: int, avg_price: float, unfilled: int) -> None: + """[2026-10-08] 접수됐던 매도가 **종결(취소·거부·잔량 0)됐는데 미체결분이 있다**. + + _finalize_exit_impl 은 접수 시점에 인메모리 제거·ActiveTrade close·TradeHistory COMPLETED + 를 이미 반영했다(10/06 KB금융 고아의 원인). 여기서 되돌릴 수 있는 것만 되돌린다: + - 당일 포지션: 미체결 수량을 인메모리에 되살려 손절·EOD 관리 재개(재시도) + - 전일·고아 포지션: 재주문 차단 표식을 지우고 [R8-1] 미체결 수량으로 ActiveTrade(day_trade, + active)를 되살려 close_stale_positions 가 다시 잡게 한다. 접수 때 행이 닫히고 reflect_sell 이 + Portfolio 수량을 0 으로 깎아, 표식만 지우면 전일 경로(active 행)·고아 경로(Portfolio 수량>0) + 어느 쪽에도 걸리지 않았고 다음 sync 가 'daytrading' 태그를 풀어 스윙/external 로 넘겼다. + - 당일/전일 손익을 실제 체결분 기준으로 보정, TradeHistory 수량·상태 보정 + loss_tracker·당일 연패 기록은 되돌리지 않는다(§8.5-27, 보수적 방향). + """ + _recent_dt_sell_finalized.pop(stock_code, None) + _today = _kst_now().date().isoformat() + _stale_sell_attempted.discard((_today, stock_code)) + _stale_cleaned.discard((_today, stock_code)) + if filled_qty > 0 and avg_price > 0: + new_net = dt_net_pnl(entry_price, avg_price, filled_qty, pos) + _net, _cost = new_net["net_profit_loss"], new_net["total_cost"] + else: + _net, _cost = 0.0, 0.0 + delta = _net - recorded_net + restored_mem = False + _restored_snapshot: dict | None = None + async with day_trading_state.trade_lock: + if count_daily_pnl: + day_trading_state.adjust_realized_pnl(delta) + _adjust_partial_realized_impl(delta) + # [R4-1] 분할 레그가 체결 0으로 종결 → 분할 자체가 없었던 것이므로 목표가·partial_sold + # 도 분할 전으로 되돌린다(수량만 되살리면 원 수량 전체가 트레일링 전용이 된다). + # 부분체결(filled_qty>0)이면 분할이 일어난 것이므로 현행(inf/True) 유지. + _pre = (pos or {}).get(_PRE_PARTIAL_STATE_KEY) if (is_partial and filled_qty <= 0) else None + _cur = day_trading_state.positions.get(stock_code) + # [HL2-2] 전량 청산 레그의 기록 정보(승패 표식·접수 시점 왕복 누적) + _full_rec = None if is_partial else (pos or {}).get(_FULL_EXIT_RECORD_KEY) + if _cur is not None: + _cur["quantity"] = int(_cur.get("quantity", 0) or 0) + int(unfilled) + _restore_pre_partial_state_impl(stock_code, _cur, _pre) + _apply_full_exit_record_impl(stock_code, _cur, _full_rec, _net, use_prior=False) + restored_mem = True + _restored_snapshot = dict(_cur) + elif not day_trading_state.is_exit_in_progress(stock_code): + _np = dict(pos) + _np.pop(_PRE_PARTIAL_STATE_KEY, None) + _np.pop(_FULL_EXIT_RECORD_KEY, None) + _np["quantity"] = int(unfilled) + _np["sell_unfilled_restored"] = True + _restore_pre_partial_state_impl(stock_code, _np, _pre) + _apply_full_exit_record_impl(stock_code, _np, _full_rec, _net, use_prior=True) + day_trading_state.add_position(stock_code, _np) + restored_mem = True + _restored_snapshot = dict(_np) + if filled_qty <= 0: + day_trading_state.dt_exited_today.discard(stock_code) + elif _orphan_today: + # [R6-1] 인메모리로 직접 되살리지 않는다(손절가 없는 근거 행 사본). 손익만 당일 기준으로 + # 되돌린다. [R8-1] 대신 아래에서 ActiveTrade(day_trade, active)를 되살린다(당일 생성 → + # close_stale_positions 의 당일 행 복원 경로가 KIS 실보유 − 타전략 확인 후 인메모리로 복원). + day_trading_state.adjust_realized_pnl(delta) + _restored_snapshot = _stale_unfilled_restore_snapshot_impl( + stock_code, pos, unfilled, orphan_today=True, + ) + if _restored_snapshot is not None: + # 당일 DT 전량 청산 기록(dt_exited_today) 뒤 보유 재개 — 복원 보류(restore_block)를 푼다. + # add_position 의 표식과 같은 의미(재기동 원장 재구성도 순체결>0 이면 reheld 로 본다). + day_trading_state.dt_reheld_today.add(stock_code) + else: + _add_stale_realized_pnl(delta) + # [R8-1] 전일·고아 잔량 — ActiveTrade 를 원 진입일(오늘 이전)로 되살려 다음 사이클 + # close_stale_positions 의 전일 경로가 다시 잡게 한다. + _restored_snapshot = _stale_unfilled_restore_snapshot_impl( + stock_code, pos, unfilled, orphan_today=False, + ) + if _restored_snapshot is not None: + # [R1-5 2026-10-09] ActiveTrade(day_trade)도 되살린다. 전량 매도 접수 때 + # _finalize_exit_impl 이 행을 닫았고(분할이면 잔량으로 줄였고), 인메모리만 복원하면 + # 재기동 시 restore_from_db 가 이 포지션을 찾지 못해 고아·관리 공백이 된다. + await _bounded_finalize_step( + _enqueue_restored_active_trade_impl(stock_code, _restored_snapshot), + stock_code, "미체결 복원 ActiveTrade 큐잉", + ) + from decimal import Decimal + from app.services.db_worker import enqueue_update_trade_history_by_order_id + _upd = { + "order_date": _order_date, + "order_type": "SELL", + "strategy_type": "daytrading", + "executed_quantity": int(filled_qty), + "commission": Decimal(str(_cost)), + "net_profit_loss": Decimal(str(round(_net, 2))), + "status": "CANCELLED" if filled_qty <= 0 else "COMPLETED", + } + if filled_qty > 0 and avg_price > 0: + _upd["executed_price"] = Decimal(str(round(avg_price, 2))) + _upd["total_amount"] = Decimal(str(round(avg_price * filled_qty, 2))) + await enqueue_update_trade_history_by_order_id(order_id, _upd) + if count_daily_pnl or _orphan_today: + await day_trading_state.save_daily_state() + if _kst_now().time() >= _KRX_MARKET_CLOSE: + # [R3-2] 장 마감 후 복원 = 더 이상 장중 재시도가 없다 → 오버나이트 확정. 잔존 점검이 먼저 + # '청산 확인'을 남겼을 수 있으므로 여기서 직접 텔레그램(DT_EOD)으로 알린다. + _alert_once_v2( + "DT_EOD", f"post_close_unfilled:{stock_code}:{order_id}", + f"[데이트레이딩] 장 마감 후 매도 미체결 확인: {stock_code} 체결 {filled_qty}/{quantity}주, " + f"미체결 {unfilled}주(order_id={order_id}) — 오버나이트 보유. 다음 영업일 장중 첫 사이클에 " + f"전일 포지션 청산이 시도됩니다. 계좌를 확인하세요.", + ) + logger.error( + f"[DayExit] 🚨 매도 미체결 종결: {stock_code} 체결 {filled_qty}/{quantity}주 " + f"(미체결 {unfilled}주) order_id={order_id} — " + + (f"인메모리 포지션 {'복원' if restored_mem else '복원 실패'}(손절·EOD 재시도), " + f"당일 손익 {delta:+.0f}원 보정" + if count_daily_pnl else + f"전일/고아 포지션 재청산 대기(close_stale_positions, ActiveTrade " + f"{'복원' if _restored_snapshot is not None else '복원 실패 — 수동 확인'}), " + f"{'당일' if _orphan_today else '전일'} 손익 {delta:+.0f}원 보정") + + ". loss_tracker·연패 기록은 되돌리지 않음" + ) + await _settle(None) + + async def _on_result(info: dict) -> None: + filled = int(info.get("filled_qty", 0) or 0) + avg = float(info.get("avg_price", 0) or 0) + if info.get("terminal") and filled < quantity: + await _on_unfilled(filled, avg, int(quantity) - filled) + return + if filled > 0 and avg > 0: + await _on_fill(filled, avg) + + if on_settled is None and not closing_auction: + # [2026-10-08] v2 — 체결 0·미체결 종결도 돌려받는다(v1 은 체결>0 일 때만 콜백). + return fill_reconciler.schedule_v2(stock_code, order_id, "SELL", int(quantity), _on_result) + if not settings.DAY_TRADE_FILL_CHECK_ENABLED: + return None + + def _is_final(info: dict | None) -> bool: + if info is None: + return False + return bool(info.get("terminal")) or int(info.get("filled_qty", 0) or 0) >= int(quantity) + + async def _run() -> None: + info = None + try: + info = await fill_reconciler.fetch_fill_v2(stock_code, order_id, "SELL", int(quantity)) + if closing_auction and not _is_final(info): + _wait = _seconds_until_auction_recheck_impl(_kst_now()) + _, _attempts, _interval = _auction_recheck_params_impl() + logger.info( + f"[DayExit] {stock_code} 종가 단일가 매도(order_id={order_id}) 체결 미종결 — " + f"{_wait:.0f}초 뒤(장 마감 후) 재확인 최대 {_attempts}회" + ) + await asyncio.sleep(_wait) + for i in range(_attempts): + _i = await fill_reconciler.fetch_fill_v2(stock_code, order_id, "SELL", int(quantity)) + if _i is not None: + info = _i + if _is_final(info): + break + if i < _attempts - 1: + await asyncio.sleep(_interval) + if info is not None: + await _on_result(info) + except Exception as e: + logger.warning( + f"[DayExit] {stock_code} 매도 체결 확인·반영 실패 order_id={order_id}: " + f"{type(e).__name__}: {e}", exc_info=True, + ) + finally: + if not _settled[0]: + # 체결 확인 불가 → 신호가 기준 값으로 확정(구 동작 폴백) + logger.warning( + f"[DayExit] {stock_code} 매도 체결 확인 불가(order_id={order_id}) — " + f"신호가 기준 순손익 {recorded_net:+.0f}원으로 확정" + ) + await _settle(recorded_net) + + try: + task = asyncio.get_running_loop().create_task(_run()) + except RuntimeError as e: + logger.warning(f"[DayExit] {stock_code} 매도 체결 확인 예약 실패: {e}") + return None + _auction_fill_tasks.add(task) + task.add_done_callback(_auction_fill_tasks.discard) + return task + + +async def _enqueue_restored_active_trade_impl(stock_code: str, pos: dict) -> None: + """[R1-5] 매도 미체결로 인메모리에 되살린 포지션을 ActiveTrade(day_trade, active)로 영속화. + + db_worker upsert 는 active day_trade 행이 있으면 갱신(분할 미체결 → 수량을 복원 후 수량으로), + 없으면 새로 만든다(전량 미체결 → 접수 때 닫힌 행 대신 새 active 행). 쓰기는 큐 경유(§3.1). + target_price 가 inf(분할 후 트레일링 전용)면 take_profit 을 넣지 않고 remaining_pct<1 로 남겨 + restore_from_db 가 목표가 분할을 재발동하지 않게 한다. + """ + from app.services.db_worker import enqueue_active_trade_upsert + + qty = int(pos.get("quantity", 0) or 0) + entry = float(pos.get("entry_price", 0) or 0) + if qty <= 0 or entry <= 0: + logger.error( + f"[DayExit] 🚨 {stock_code} 미체결 복원 ActiveTrade 생략(수량 {qty}·진입가 {entry}) — 수동 확인 필요" + ) + return + payload: dict = { + "stock_code": stock_code, + "stock_name": pos.get("stock_name") or stock_code, + "strategy_name": "day_trade", + "quantity": qty, + "entry_price": entry, + "status": "active", + } + _sl = pos.get("stop_loss") + if _sl is not None and 0 < float(_sl) < float("inf"): + payload["stop_loss"] = float(_sl) + _tp = pos.get("target_price") + if _tp is not None and 0 < float(_tp) < float("inf"): + payload["take_profit"] = float(_tp) + _hi = pos.get("highest_price") + if _hi is not None and 0 < float(_hi) < float("inf"): + payload["highest_since_entry"] = float(_hi) + if pos.get("partial_sold"): + payload["remaining_pct"] = 0.99 + else: + # [R4-1] 분할 레그 0체결 복원 — 분할 때 기록한 remaining_pct(<1.0)를 1.0 으로 되돌려야 + # restore_from_db 가 목표가 분할을 막지 않는다(분할 이력 없음 = 원 수량 전체). + payload["remaining_pct"] = 1.0 + if pos.get("reason"): + payload["entry_reason"] = str(pos.get("reason")) + # [HL2-1 2026-10-09] 원 진입 시각·매수 ODNO 를 싣는다(둘 다 기존 ORM 컬럼 — 선택 키). + # 전량 미체결 복원은 새 active 행을 만드는데, 없으면 created_at=복원 시각(now_kst 기본값)이 + # 되어 재기동 후 entry_time(=created_at)이 리셋 → 30분 시간손절·트레일링 최소 보유가 늦게 + # 발동하고, order_id 가 NULL 이라 R3 의 ODNO 영속화가 이 경로에서 다시 빠진다. + _ct = _restored_created_at_impl(pos.get("entry_time")) + if _ct is not None: + payload["created_at"] = _ct + else: + logger.warning( + f"[DayExit] {stock_code} 미체결 복원 ActiveTrade — 원 진입 시각 없음/형식 불량" + f"({pos.get('entry_time')!r}) → created_at 은 복원 시각(시간손절 시계 리셋 가능)" + ) + _oid = str(pos.get("order_id") or "").strip() + if _oid: + payload["order_id"] = _oid + await enqueue_active_trade_upsert(payload) + + +def _restored_created_at_impl(entry_time) -> datetime | None: + """복원 ActiveTrade.created_at 값 — 원 진입 시각을 KST naive 로(모델 기본값 now_kst 와 같은 기준). + + datetime 이 아니거나 미래 시각이면 None(호출부가 키를 넣지 않아 모델 기본값을 쓴다). + 정규장 밖 시각도 그대로 둔다 — restore 쪽 _elapsed_minutes/_normalize_entry_time 이 판정한다. + """ + if not isinstance(entry_time, datetime): + return None + if entry_time.tzinfo is not None: + entry_time = entry_time.astimezone(_KST).replace(tzinfo=None) + if entry_time > _kst_now(): + return None + return entry_time + + +def _stale_unfilled_restore_snapshot_impl( + stock_code: str, pos: dict, unfilled: int, orphan_today: bool, +) -> dict | None: + """[R8-1 2026-10-10] 전일·고아 매도 0체결/부분 종결 → ActiveTrade 복원용 포지션 사본(없으면 None). + + _enqueue_restored_active_trade_impl 에 넘길 dict 를 만든다. 수량은 **미체결분만**이다 + (접수 때 sell_qty 는 이미 KIS 실보유 − 타전략(§3.5)·주문가능 상한으로 잘렸다 — 그 이상은 DT 몫이 + 아니다). 다음 close_stale_positions 가 매도 시점에 다시 KIS 실보유 − 타전략으로 상한을 건다. + + created_at(=entry_time) 규칙: + - 전일·과거 고아(orphan_today=False): 원 진입 시각. 반드시 **오늘 이전**이어야 전일 경로에 + 잡힌다 — 시각이 없거나 오늘 이후면 전일 15:30(KST)으로 대체(warning). 오늘 날짜로 들어가면 + 당일 행 복원 경로가 손절가 없는 사본을 '당일 진입'으로 되살린다. + - 당일 고아(orphan_today=True): 원 진입 시각(당일) → 당일 행 복원 경로(인메모리 관리 재개). + """ + try: + qty = int(unfilled or 0) + except (TypeError, ValueError): + qty = 0 + if qty <= 0: + return None + snap = dict(pos or {}) + for _k in (_PRE_PARTIAL_STATE_KEY, _FULL_EXIT_RECORD_KEY, "orphan", _ORPHAN_TODAY_KEY): + snap.pop(_k, None) + snap["quantity"] = qty + snap["strategy"] = "day_trade" + snap["sell_unfilled_restored"] = True + # 분할 이력 없음(전일·고아 매도는 항상 전량 레그) — remaining_pct=1.0 로 복원된다. + snap.pop("partial_sold", None) + if not orphan_today: + today = _kst_now().date() + _ct = _restored_created_at_impl(snap.get("entry_time")) + if _ct is None or _ct.date() >= today: + _fallback = datetime.combine(today - timedelta(days=1), _KRX_MARKET_CLOSE) + logger.warning( + f"[DayExit] {stock_code} 전일·고아 미체결 복원 — 원 진입 시각 " + f"{snap.get('entry_time')!r} 이 오늘 이전이 아니거나 없음 → created_at={_fallback} 로 " + f"기록(전일 경로 재청산 보장, 실제 진입일 아님)" + ) + snap["entry_time"] = _fallback + else: + snap["entry_time"] = _ct + return snap + + +# ── [2026-10-08] DT 고아 잔고 감지 ──────────────────────────────────────────────── +# 동기: 10/06 KB금융(105560) EOD 시장가 매도가 **접수(rt_cd=0)** 되자 _finalize_exit_impl 이 +# 체결 확인 없이 인메모리 제거 + ActiveTrade close + TradeHistory COMPLETED 를 남겼다. 이 구조에서는 +# 실제 0체결(취소/미체결)이면 잔고가 남는다. +# [정정 2026-10-09] 105560 의 잔여 3주가 "10/06 EOD 0체결"이라는 서술은 **운영 DB 와 맞지 않는다**: +# - ActiveTrade id=178 'synced' 3주(평단 171,058.33)가 10/01 08:55 — 그날 DT 매수(13:44) **이전** 에 생성. +# - ActiveTrade id=177(9/30 day_trade)이 수량 6 으로 기록됐는데 9/30 DT 매수 TradeHistory 는 3주 +# → 9/30 이전부터 DT 외 3주가 있었고 DT 행에 합산된 것으로 보인다(추정). +# - TradeHistory(daytrading) BUY 3+3+3 / SELL 3+3+3 = 순 0. 10/06 SELL(order_id 0000031910)은 +# slippage NULL = 체결 확인이 안 된 채 COMPLETED(체결 여부는 KIS 주문내역 미확인 — 추정 불가). +# → 잔여 3주는 DT 0체결분이 아니라 기존 외부/동기화 보유분일 가능성이 높다(추정). 그래서 아래 +# 감지는 TradeHistory 순보유 0 인 105560 을 **팔지 않고** 수동 확인 경보만 낸다(R1-1). +# Portfolio 행은 sync 가 'daytrading' 태그를 유지한 채 수량만 갱신했고, 이후 +# - restore_from_db / close_stale_positions 는 **active** ActiveTrade 만 보고(행이 closed), +# - 스윙 경로(execute_auto_trade)는 daytrading 소유 행을 매도하지 않으며(§6.4), +# - sync_portfolio 는 'daytrading' 행에 ActiveTrade 를 시딩하지 않는다 +# → 아무도 손절을 걸지 않는 고아가 됐다. 아래 감지는 close_stale_positions 의 읽기 전용 +# 세션(§3.2 ③) 안에서만 수행하고, 매도는 기존 stale 경로(실보유·타전략 차감·재시도· +# count_daily_pnl=False·closed_today)를 그대로 탄다. + +# [R4-1 2026-10-09] 분할 레그 체결 확인용 포지션 스냅샷에 싣는 "분할 전 상태" 키 +# ({"target_price": 분할 전 목표가, "partial_sold": 분할 전 값}). 체결 0 종결 시 복원에 쓴다. +_PRE_PARTIAL_STATE_KEY = "_pre_partial_state" + + +def _apply_full_exit_record_impl( + stock_code: str, target: dict, rec, filled_net: float, use_prior: bool, +) -> None: + """[HL2-2] 전량 청산 레그가 0체결·부분 종결로 복원될 때 복원 포지션에 남길 상태. + + - 승패 표식: 접수 시점에 당일 연패·loss_tracker 에 기록한 결과(재청산 시 이중 집계 방지) + - realized_net: 체결된 레그의 순손익(filled_net)을 왕복 누적에 더한다 — 빠지면 다음 전량 + 청산의 왕복 판정(_prior_realized + 마지막 레그)에서 그 레그가 누락된다. + use_prior=True 면 접수 시점 누적(prior_realized)을 기준으로, False 면 대상의 현재값에 더한다. + rec 가 없으면(분할 레그·구 스냅샷) 아무것도 하지 않는다. + """ + if not isinstance(rec, dict): + return + for _key, _src_key in ( + (_RESTORED_STREAK_MARK_KEY, "streak"), + (_RESTORED_LOSS_TRACKER_MARK_KEY, "loss_tracker"), + ): + _v = rec.get(_src_key) + if isinstance(_v, bool): + target[_key] = _v + elif use_prior: + target.pop(_key, None) + if use_prior: + try: + _base = float(rec.get("prior_realized", 0.0) or 0.0) + except (TypeError, ValueError): + logger.warning(f"[DayExit] {stock_code} 복원 prior_realized 파싱 실패 — 0 기준") + _base = 0.0 + else: + try: + _base = float(target.get("realized_net", 0.0) or 0.0) + except (TypeError, ValueError): + logger.warning(f"[DayExit] {stock_code} 복원 realized_net 파싱 실패 — 0 기준") + _base = 0.0 + target["realized_net"] = _base + float(filled_net or 0.0) + if filled_net: + logger.warning( + f"[DayExit] {stock_code} 매도 부분체결 복원 — 체결 레그 순손익 {filled_net:+.0f}원을 " + f"왕복 누적에 반영(realized_net={target['realized_net']:+.0f}원)" ) - return fill_reconciler.schedule(stock_code, order_id, "SELL", _on_fill) + +def _restore_pre_partial_state_impl(stock_code: str, target: dict, pre) -> None: + """[R4-1] 체결 0 으로 종결된 분할 레그 — 목표가·partial_sold 를 분할 전 값으로 되돌린다.""" + if not isinstance(pre, dict): + return + _tp = pre.get("target_price") + if _tp is not None: + target["target_price"] = _tp + target["partial_sold"] = bool(pre.get("partial_sold", False)) + logger.warning( + f"[DayExit] {stock_code} 분할 매도 체결 0 종결 — 목표가({_tp})·partial_sold" + f"({target['partial_sold']}) 분할 전 상태로 복원(목표가 분할 재시도 가능)" + ) + + +# 최근 DT 매도 반영 시각 {code: monotonic} — 반영 직후 Portfolio/KIS 가 아직 옛 수량일 때 +# 고아로 오판하지 않게 DAY_TRADE_ORPHAN_GRACE_SEC 동안 보류한다. 0체결 확인 시 해제. +_recent_dt_sell_finalized: dict[str, float] = {} + + +def _orphan_blocked_reason(stock_code: str, today_key: str) -> str | None: + """고아/복원 판정을 보류해야 하면 사유, 아니면 None.""" + return _orphan_blocked_reason_v2(stock_code, today_key, block_unresolved_unknown=True) + + +def _orphan_blocked_reason_v2( + stock_code: str, today_key: str, block_unresolved_unknown: bool, +) -> str | None: + """[R1-2 2026-10-09] 고아/복원 판정 보류 사유. + + block_unresolved_unknown=False(고아 후보 전용): 직전 고아 매도가 **결과 불명**이어도 후보에서 + 빼지 않는다. 빼면 `_place_and_finalize_exit_impl` 의 불명 재판정(잔고 기준)에 다시 도달하지 + 못해 `_unresolved_exit_unknown` 이 당일 내내 풀리지 않고 고아가 오버나이트로 남았다. + 재판정·재매도 상한은 stale 경로(_stale_sell_failures 백오프 → _resolve_unknown_exit_v2_impl)가 + 그대로 맡는다. 진행 중 표식(HOLD) 동안은 아래 '청산 주문 진행 중'이 계속 막는다. + """ + if stock_code in day_trading_state.positions: + return "인메모리 보유" + if day_trading_state.is_exit_in_progress(stock_code): + return "청산 주문 진행 중" + if block_unresolved_unknown and (today_key, stock_code) in _unresolved_exit_unknown: + return "결과 불명 매도 미해결" + _t = _recent_dt_sell_finalized.get(stock_code) + if _t is not None and time.monotonic() - _t < float(settings.DAY_TRADE_ORPHAN_GRACE_SEC): + return "최근 DT 매도 반영(유예)" + return None + + +# [R1-3/6/7] 수동 개입 경보를 보낸 (날짜, key) — 하루 1회만 텔레그램(로그는 _error_throttled) +_manual_alerted: set[tuple[str, str]] = set() + + +def _alert_manual_intervention_impl(key: str, msg: str) -> None: + """[R1-3/6/7] 자동 처리 주체가 없는 잔고 → logger.error(스로틀) + 텔레그램(하루 1회). + + warn_throttled 경고는 놓치기 쉬웠다(KB금융 105560 3주가 10/01부터 DT·스윙 어느 쪽도 + 관리하지 않는 상태로 남았다). 발송 실패는 error 로그만 남기고 흐름을 막지 않는다. + """ + _error_throttled(f"dt_exit:manual:{key}", msg) + _k = (_today_key(), key) + for k in [k for k in _manual_alerted if k[0] != _k[0]]: + _manual_alerted.discard(k) + if _k in _manual_alerted: + return + _manual_alerted.add(_k) + try: + from app.services.telegram_notifier import telegram_notifier + _t = asyncio.get_running_loop().create_task(asyncio.wait_for( + telegram_notifier.notify_system_warning("DT_MANUAL_CHECK", msg), timeout=10.0, + )) + _alert_tasks.add(_t) + _t.add_done_callback(_alert_tasks.discard) + except Exception as e: + logger.error(f"[DayExit] 수동 확인 경보 발송 실패({key}): {e}") + + +def _dt_net_qty_from_history(rows) -> dict[str, int]: + """[R1-1] TradeHistory(daytrading) 행 → 종목별 DT 순보유 추정(BUY 체결 − SELL 체결). 순수 함수. + + 보수적 집계(과다매도 방지 쪽으로 기운다): + - BUY 는 status=='COMPLETED' 만, 체결수량(없으면 주문수량)으로 센다. + - SELL 은 FAILED/REJECTED 를 뺀 전부를 센다 — PENDING(결과 불명)도 팔린 것으로 본다. + 0체결 종결은 fill_reconciler 가 CANCELLED·executed_quantity=0 으로 바꿔 두므로 0 으로 들어간다. + 창 밖에서 산 물량을 창 안에서 판 경우 음수가 될 수 있다 → 호출부가 0 하한으로 쓴다(매도 안 함). + """ + out: dict[str, int] = {} + for r in rows or []: + code = getattr(r, "stock_code", None) + if not code: + continue + ot = str(getattr(r, "order_type", "") or "").upper() + st = str(getattr(r, "status", "") or "").upper() + eq = getattr(r, "executed_quantity", None) + q = int(eq if eq is not None else (getattr(r, "order_quantity", 0) or 0)) + if ot == "BUY": + if st == "COMPLETED": + out[code] = out.get(code, 0) + q + elif ot == "SELL": + if st not in ("FAILED", "REJECTED"): + out[code] = out.get(code, 0) - q + return out + + +def _row_created_date_v2(row): + """[R6-1] 행의 created_at(KST naive 저장) 날짜. 없거나 파싱 불가면 None.""" + _c = getattr(row, "created_at", None) + if isinstance(_c, datetime): + return _c.date() + return None + + +def _last_completed_dt_buy_at_v2(rows, today=None) -> dict: + """[R6-1] {(code, is_today): 마지막 COMPLETED DT BUY created_at}. 순수 함수. + + today 를 주지 않으면 KST 오늘을 쓴다. 고아 청산 사유·entry_time 의 '진입일'을 근거 + ActiveTrade 행(과거 행일 수 있음)이 아니라 실제 매수 체결 기준으로 쓰기 위한 것이다. + """ + _today = today or _kst_now().date() + out: dict = {} + for r in rows or []: + code = getattr(r, "stock_code", None) + if not code: + continue + if str(getattr(r, "order_type", "") or "").upper() != "BUY": + continue + if str(getattr(r, "status", "") or "").upper() != "COMPLETED": + continue + _c = getattr(r, "created_at", None) + if not isinstance(_c, datetime): + continue + key = (code, _c.date() == _today) + if key not in out or _c > out[key]: + out[key] = _c + return out + + +async def _detect_dt_orphans_impl(db, active_dt_rows, stale: dict, today) -> tuple[dict, list]: + """(고아 후보 {code: pos}, 복원 대상 ActiveTrade 행 목록). + + 호출부(close_stale_positions)의 읽기 전용 세션을 그대로 쓴다 — SELECT 만, commit 없음. + - 복원: 당일 생성 active day_trade 행인데 인메모리에 없고 당일 DT 전량 청산 기록도 없는 종목 + (restore_block_reason_v2 — [R2 하드닝] closed_today 는 재진입 차단 집합이라 근거로 쓰지 않는다). + - 고아: Portfolio.strategy_type ∈ DT 이고 수량>0 인데 인메모리·active DT 행이 없는 종목. + 근거로 DAY_TRADE_ORPHAN_LOOKBACK_DAYS 이내 DT ActiveTrade 행(상태 무관)이 있어야 하며, + 매도 상한 = min(Portfolio 수량, 그 행 수량, TradeHistory DT 순보유). + + [R1-1 2026-10-09] **정상 체결로 닫힌 DT 행은 근거가 아니다.** Portfolio 'daytrading' 태그는 + 같은 종목의 수동·HTS 매수분이 합산돼도 해제되지 않는다(§3.5 — 행이 stock_code UNIQUE). + 예전에는 최근 DT 행이 있기만 하면 그 수량만큼 팔아, DT 3주 정상 청산 후 남은 수동 10주에서 + 3주를 매일 시장가 매도했다. 이제 같은 창의 TradeHistory(strategy_type='daytrading') + BUY−SELL 순체결수량(_dt_net_qty_from_history)이 양수여야만 고아로 본다 — 0체결 종결된 매도는 + fill_reconciler 가 CANCELLED·체결 0 으로 바꾸므로 그 몫만 양수로 남는다. + 근거가 없거나 순보유 0 이면 팔지 않고 **logger.error + 텔레그램**(수동 확인, R1-3/6/7). + """ + from app.models.models import ActiveTrade, Portfolio, TradeHistory + from app.services.db_worker import DT_STRATEGY_NAMES + from sqlalchemy import select as _select + + _tk = today.isoformat() + restores: list = [] + active_codes = set() + for r in active_dt_rows or []: + active_codes.add(r.stock_code) + _c = r.created_at.date() if getattr(r, "created_at", None) else None + if _c is None or _c < today or r.stock_code in stale: + continue + # [R2 하드닝 2026-10-09] closed_today(재진입 차단)가 아니라 DT 전량 청산 기록으로 판단한다. + # 불명 매수 접수 확정·매도 0체결 종결 복원 종목은 closed_today 에 남아 있어도 보유 중이다. + _rb = day_trading_state.restore_block_reason_v2(r.stock_code) + if _rb is not None: + warn_throttled( + logger, f"dt_exit:restore_block:{r.stock_code}", + f"[DayExit] ⚠️ {r.stock_code}: 당일 active DT 행({int(r.quantity or 0)}주)이 인메모리에 없으나 " + f"{_rb} — 복원 보류(close 큐 반영 지연이면 정상, 실보유가 남아 있으면 수동 확인)", + ) + continue + if _orphan_blocked_reason(r.stock_code, _tk) is not None: + continue + if int(r.quantity or 0) > 0: + restores.append(r) + + pf_rows = (await db.execute( + _select(Portfolio).where( + Portfolio.strategy_type.in_(DT_STRATEGY_NAMES), + Portfolio.quantity > 0, + ) + )).scalars().all() + cands: dict[str, object] = {} + for p in pf_rows or []: + code = getattr(p, "stock_code", None) + if not code or code in stale or code in active_codes: + continue + if int(getattr(p, "quantity", 0) or 0) <= 0: + continue + if _orphan_blocked_reason_v2(code, _tk, block_unresolved_unknown=False) is not None: + continue + cands[code] = p + if not cands: + return {}, restores + + _since = datetime.combine( + today - timedelta(days=int(settings.DAY_TRADE_ORPHAN_LOOKBACK_DAYS)), datetime.min.time(), + ) + ev_rows = (await db.execute( + _select(ActiveTrade).where( + ActiveTrade.stock_code.in_(list(cands)), + ActiveTrade.strategy_name.in_(DT_STRATEGY_NAMES), + ActiveTrade.created_at >= _since, + ).order_by(ActiveTrade.created_at.desc()) + )).scalars().all() + latest: dict[str, object] = {} + for r in ev_rows or []: + if r.stock_code not in latest: + latest[r.stock_code] = r + # [R1-1] 같은 창의 DT 원장 — 순체결수량이 양수인 종목만 DT 몫이 남아 있다고 본다. + th_rows = (await db.execute( + _select(TradeHistory).where( + TradeHistory.stock_code.in_(list(cands)), + TradeHistory.strategy_type == "daytrading", + TradeHistory.created_at >= _since, + ) + )).scalars().all() + net_by_code = _dt_net_qty_from_history(th_rows) + # [R6-1 2026-10-10] 당일 창(KST 오늘 생성 행)만의 DT 순체결 — 양수면 '당일 매수분' 고아다. + # 그 손익은 전일 포지션 예외(§7 count_daily_pnl=False)가 아니라 당일 집계 대상이다. + # created_at 이 없는 행은 당일로 보지 않는다(근거 불명 → 기존 동작). + today_rows = [r for r in th_rows or [] if _row_created_date_v2(r) == today] + today_net_by_code = _dt_net_qty_from_history(today_rows) + last_buy_at = _last_completed_dt_buy_at_v2(th_rows, today) + + _lb = int(settings.DAY_TRADE_ORPHAN_LOOKBACK_DAYS) + orphans: dict[str, dict] = {} + for code, p in cands.items(): + pf_qty = int(getattr(p, "quantity", 0) or 0) + ev = latest.get(code) + ev_qty = int(getattr(ev, "quantity", 0) or 0) if ev is not None else 0 + th_net = max(0, int(net_by_code.get(code, 0))) + if ev is None or ev_qty <= 0 or th_net <= 0: + _why = ( + f"최근 {_lb}일 DT 진입 근거(ActiveTrade) 없음" if ev is None or ev_qty <= 0 + else f"최근 {_lb}일 DT 원장 순체결 {int(net_by_code.get(code, 0))}주(매수=매도 — DT 몫 없음)" + ) + _alert_manual_intervention_impl( + f"orphan_unowned:{code}", + f"[DayExit] 🚨 {code}: Portfolio 'daytrading' {pf_qty}주인데 인메모리·active DT 행이 없고 " + f"{_why} — DT 는 자동 청산하지 않는다. 스윙 청산도 'daytrading' 태그 행은 건너뛰므로 " + f"손절 주체가 없는 잔고일 수 있다(수동 매수 합산·sync 'synced' 행 공존 포함). " + f"수동 청산 또는 Portfolio 태그 재지정 필요", + ) + continue + _cap = min(pf_qty, ev_qty, th_net) + _entry = float(getattr(ev, "entry_price", 0) or 0) or float(getattr(p, "average_price", 0) or 0) + # [R6-1] 당일 DT 순매수가 남아 있으면 당일 포지션 — 청산 손익을 당일 한도·연패에 넣는다. + _today_net = int(today_net_by_code.get(code, 0)) + _is_today = _today_net > 0 + # 진입 시각은 근거 ActiveTrade 생성시각(과거 행일 수 있다)이 아니라 실제 DT 매수 체결 행 기준. + # 당일 고아면 당일 마지막 매수, 아니면 오늘 이전 마지막 매수 → 없으면 근거 행 시각(구 동작). + _buy_at = last_buy_at.get((code, True)) if _is_today else last_buy_at.get((code, False)) + _entry_time = _buy_at or getattr(ev, "created_at", None) + orphans[code] = { + "stock_name": getattr(p, "stock_name", None) or getattr(ev, "stock_name", None) or code, + "strategy": "day_trade", + "entry_price": _entry, + "quantity": _cap, + "highest_price": float(getattr(ev, "highest_since_entry", 0) or 0) or _entry, + "entry_time": _entry_time, + "reason": "", + "order_id": "", + "orphan": True, + _ORPHAN_TODAY_KEY: _is_today, + } + logger.warning( + f"[DayExit] ⚠️ DT 고아 잔고 감지: {code} Portfolio 'daytrading' {pf_qty}주, 최근 DT 행 " + f"(status={getattr(ev, 'status', '?')}, 수량 {ev_qty}, 생성 {getattr(ev, 'created_at', None)}), " + f"DT 원장 순체결 {th_net}주(당일 {_today_net}주) — 인메모리·active 행 없음 → 전량 청산 대상" + f"(상한 {_cap}주, {'당일 매수분 — 당일 손익 집계' if _is_today else '전일 이전 — 당일 손익 제외'})" + ) + return orphans, restores + + +async def _restore_lost_today_positions_impl(rows, kis_holdings: dict, other_qty: dict) -> int: + """당일 active 행 → 인메모리 복원 (restore_from_db 와 같은 변환). 복원 수 반환.""" + from app.services.daytrading.position_manager import _active_trade_row_to_position + + n = 0 + for r in rows: + code = r.stock_code + qty = int(r.quantity or 0) + _h = kis_holdings.get(code) or {} + hldg = int(_h.get("hldg", 0) or 0) + dt_avail = hldg - int(other_qty.get(code, 0) or 0) + if qty <= 0 or dt_avail < qty: + warn_throttled( + logger, f"dt_exit:restore_skip:{code}", + f"[DayExit] ⚠️ {code}: 당일 active DT 행({qty}주)이 인메모리에 없으나 실보유 {hldg}주 − " + f"타전략 {int(other_qty.get(code, 0) or 0)}주 = {dt_avail}주 < 행 수량 — 복원 보류(수동 확인)", + ) + continue + async with day_trading_state.trade_lock: + _rb = None if code in day_trading_state.positions else day_trading_state.restore_block_reason_v2(code) + if code in day_trading_state.positions or _rb is not None: + if _rb is not None: + warn_throttled( + logger, f"dt_exit:restore_block:{code}", + f"[DayExit] ⚠️ {code}: 당일 active DT 행 복원 직전 {_rb} — 복원 보류", + ) + continue + day_trading_state.add_position(code, _active_trade_row_to_position(r)) + n += 1 + logger.warning( + f"[DayExit] ⚠️ 당일 DT 포지션 인메모리 유실 → ActiveTrade 에서 복원: {code} {qty}주 " + f"(실보유 {hldg}주) — 손절·트레일링·EOD 관리 재개" + ) + return n async def close_stale_positions() -> list[dict]: @@ -1514,6 +2967,7 @@ async def close_stale_positions() -> list[dict]: # [H3 2026-10-02] 같은 세션에서 비-DT(스윙·유입 등) active 행 수량도 읽는다(§3.5 차감용). # 조회 실패면 None 으로 남겨 **매도하지 않는다**(과다매도 = 스윙 물량 매도 방지). other_strategy_qty: dict[str, int] | None = None + restore_rows: list = [] try: from app.core.database import AsyncSessionLocal from app.models.models import ActiveTrade @@ -1555,6 +3009,26 @@ async def close_stale_positions() -> list[dict]: other_strategy_qty[_o.stock_code] = ( other_strategy_qty.get(_o.stock_code, 0) + int(_o.quantity or 0) ) + # [2026-10-08] DT 고아 잔고 / 당일 active 행 유실 감지 — 같은 읽기 전용 세션. + # 위 쿼리 순서(①DT active ②비-DT)는 바꾸지 않고 뒤에 덧붙인다. + if settings.DAY_TRADE_ORPHAN_DETECT_ENABLED: + _orphans, _restores = await _detect_dt_orphans_impl(_db, _rows, stale, today) + stale.update(_orphans) + restore_rows = _restores + _extra = [c for c in list(_orphans) + [r.stock_code for r in _restores] + if c not in other_strategy_qty] + if _extra: + _other_rows2 = (await _db.execute( + _select(ActiveTrade).where( + ActiveTrade.stock_code.in_(_extra), + ActiveTrade.status == "active", + not_dt_strategy_clause(ActiveTrade.strategy_name), + ) + )).scalars().all() + for _o in _other_rows2: + other_strategy_qty[_o.stock_code] = ( + other_strategy_qty.get(_o.stock_code, 0) + int(_o.quantity or 0) + ) except Exception as _db_err: logger.warning( f"[DayExit] 전일 포지션 DB 조회 실패(인메모리만 처리, 타 전략 보유분 미확인 → " @@ -1565,11 +3039,31 @@ async def close_stale_positions() -> list[dict]: stale = { c: p for c, p in stale.items() if int((p or {}).get("quantity", 0) or 0) > 0 } - if not stale: + if not stale and not restore_rows: return results # [H3] KIS 실보유 조회 — 실패하면 None → 매도하지 않는다(warning). - kis_holdings = await _fetch_kis_holdings_v2_impl() + kis_holdings, _holdings_complete = await _fetch_kis_holdings_v3_impl() + + # [2026-10-08] 당일 active 행이 있는데 인메모리에서 사라진 포지션 → 복원(정상 관리 재개). + # 실보유에서 타 전략 몫을 뺀 DT 가용이 행 수량 이상일 때만(이미 팔린 행을 되살려 스윙 + # 물량을 파는 것을 막는다). 판정 불가(조회 실패)면 복원하지 않는다 — 다음 사이클 재시도. + if restore_rows and kis_holdings is not None and other_strategy_qty is not None: + # [R3-1] 첫 페이지가 가득 차 응답에 없는 종목은 '보유 0'이 아니라 판정 불가 → 이번 회차 보류. + _judgeable = [] + for _r in restore_rows: + if _holding_unknown_v2(kis_holdings, _holdings_complete, _r.stock_code): + _error_throttled( + f"dt_exit:restore_page_full:{_r.stock_code}", + f"[DayExit] 🚨 {_r.stock_code}: 당일 active DT 행이 인메모리에 없으나 KIS 잔고 첫 페이지가 " + f"가득 차 실보유를 확인할 수 없음 — 복원 보류(손절 공백, 수동 확인 필요)", + ) + continue + _judgeable.append(_r) + if _judgeable: + await _restore_lost_today_positions_impl(_judgeable, kis_holdings, other_strategy_qty) + if not stale: + return results for stock_code, pos in stale.items(): entry_time = pos.get("entry_time") @@ -1605,6 +3099,17 @@ async def close_stale_positions() -> list[dict]: ) continue + if _holding_unknown_v2(kis_holdings, _holdings_complete, stock_code): + # [R3-1] 응답에 없는데 첫 페이지가 가득 참 → '보유 0'(=매도 없이 ActiveTrade 정리)으로 + # 해석하면 실보유가 남은 채 행이 닫혀 손절 주체가 없는 고아가 된다. 매도·정리 모두 보류. + _alert_manual_intervention_impl( + f"stale_page_full:{stock_code}", + f"[DayExit] 🚨 전일 DT 포지션 {stock_code}({row_qty}주): KIS 잔고 첫 페이지가 가득 차" + f"(≥{int(getattr(settings, 'SWING_BALANCE_FULL_PAGE_ROWS', 20) or 0)}행) 실보유를 확인할 수 " + f"없음 — 자동 매도·정리 보류(ActiveTrade 유지). get_balance 연속조회 미구현(§8.5-37), " + f"계좌 확인 후 수동 청산 필요", + ) + continue _h = kis_holdings.get(stock_code) or {"hldg": 0, "sellable": 0} hldg_qty = int(_h.get("hldg", 0) or 0) sellable_qty = int(_h.get("sellable", 0) or 0) @@ -1614,6 +3119,28 @@ async def close_stale_positions() -> list[dict]: dt_available = max(0, min(sellable_qty, hldg_qty - other_qty)) sell_qty = min(row_qty, dt_available) + _is_orphan = bool(pos.get("orphan")) + if _is_orphan and (hldg_qty <= 0 or (sell_qty <= 0 and not _already_accepted)): + # 고아 후보는 닫을 ActiveTrade 가 없다 — 실보유 0(이미 팔림·Portfolio 미동기화)이거나 + # DT 가용 0(타 전략 몫)이면 아무것도 하지 않는다. + if hldg_qty <= 0: + warn_throttled( + logger, f"dt_exit:orphan_nothing:{stock_code}", + f"[DayExit] DT 고아 후보 {stock_code}: KIS 보유 0주 — 매도 대상 없음" + f"(Portfolio 미동기화 추정)", + ) + else: + # [R1-3/7] 실보유가 있는데 DT 가용 0 = 'daytrading' 태그 행을 DT 는 타 전략 몫으로, + # 스윙은 DT 소유로 보고 서로 미루는 관리 공백일 수 있다 → 수동 개입 경보. + _alert_manual_intervention_impl( + f"orphan_no_dt_available:{stock_code}", + f"[DayExit] 🚨 DT 고아 후보 {stock_code}: KIS 보유 {hldg_qty}주(주문가능 " + f"{sellable_qty}주) − 비-DT active 행 {other_qty}주 → DT 가용 {dt_available}주 — " + f"DT 는 팔지 않는다. Portfolio 태그가 'daytrading' 이면 스윙 청산도 건너뛰므로 " + f"손절 주체가 없을 수 있다. 수동 청산 또는 Portfolio 태그 재지정 필요", + ) + continue + if hldg_qty <= 0 and _attempt_key not in _stale_cleaned: # [L-1] 실보유 0 — 이미 팔렸다(접수된 매도의 체결 포함). 주문 없이 ActiveTrade 만 정리. sell_qty = 0 @@ -1712,10 +3239,34 @@ async def close_stale_positions() -> list[dict]: if price <= 0: continue - logger.warning( - f"[DayExit] ⚠️ 전일 미청산 포지션 즉시 청산: {stock_code} " - f"(진입일={entry_date}, 수량={sell_qty}/{row_qty})" - ) + if _is_orphan and _is_orphan_today_position(pos): + # [R6-1] 당일 매수분 — 사유 접두사를 분리해 재기동 원장 재구성이 당일 손익에 포함하게 한다 + # (position_manager._STALE_EXIT_REASON_PREFIXES 와 겹치지 않음). 손익은 당일 집계. + _exit_reason = ( + f"{_ORPHAN_TODAY_EXIT_REASON_PREFIX} (진입일 {entry_date or '미상'}, 인메모리·active 행 " + f"없음 — 당일 매수 후 포지션 기록 유실 추정, 당일 손익 집계)" + ) + logger.warning( + f"[DayExit] ⚠️ DT 당일 고아 잔고 즉시 청산: {stock_code} " + f"(진입일={entry_date or '미상'}, 수량={sell_qty}/{row_qty}, KIS 보유 {hldg_qty}·" + f"주문가능 {sellable_qty} − 타전략 {other_qty}) — 당일 손익·연패 반영" + ) + elif _is_orphan: + _exit_reason = ( + f"DT 고아 잔고 청산 (진입일 {entry_date or '미상'}, 인메모리·active 행 없음 — " + f"EOD/청산 매도 미체결 추정)" + ) + logger.warning( + f"[DayExit] ⚠️ DT 고아 잔고 즉시 청산: {stock_code} " + f"(진입일={entry_date or '미상'}, 수량={sell_qty}/{row_qty}, KIS 보유 {hldg_qty}·" + f"주문가능 {sellable_qty} − 타전략 {other_qty})" + ) + else: + _exit_reason = f"전일 미청산 포지션 강제 청산 (진입일 {entry_date}, EOD 누락)" + logger.warning( + f"[DayExit] ⚠️ 전일 미청산 포지션 즉시 청산: {stock_code} " + f"(진입일={entry_date}, 수량={sell_qty}/{row_qty})" + ) # [M10] 청산 성공/실패와 무관하게 당일 재진입은 금지한다. 그 사이 # scan_and_enter 가 같은 종목을 다시 매수하면 전일 포지션이 되살아난다. async with day_trading_state.trade_lock: @@ -1724,6 +3275,7 @@ async def close_stale_positions() -> list[dict]: await day_trading_state.save_daily_state() # [M1] 전일 손익은 당일 daily_pnl / loss_streak_today 에 가산하지 않는다. + # [R6-1] 단 당일 매수분 고아(orphan_today)는 _finalize_exit_impl 이 당일 집계에 넣는다. _last_exit_order_outcome.pop(stock_code, None) _last_exit_order_detail.pop(stock_code, None) _stale_last_qty[_attempt_key] = sell_qty @@ -1731,8 +3283,7 @@ async def close_stale_positions() -> list[dict]: result = None try: result = await _execute_exit_stale( - stock_code, sell_pos, price, - reason=f"전일 미청산 포지션 강제 청산 (진입일 {entry_date}, EOD 누락)", + stock_code, sell_pos, price, reason=_exit_reason, ) except Exception as e: logger.error(f"[DayExit] 전일 포지션 {stock_code} 매도 예외: {e}", exc_info=True) @@ -1780,11 +3331,56 @@ async def _stale_sell_accepted_today_impl(stock_code: str, qty: int | None = Non return _find_accepted_sell_row(rows, stock_code, qty, since) is not None +def _balance_rows_page_full_v2(n_rows: int) -> bool: + """[R3-1] get_balance 첫 페이지가 가득 찼는가 — 응답 밖(다음 페이지)에 종목이 있을 수 있다. + + get_balance 는 연속조회 없이 첫 페이지만 읽는다(모의 20행/실전 50행, CLAUDE.md §8.5-37). + 판정 기준은 스윙(swing/exit_service)·MDD(mdd_manager._balance_page_full_v2)와 같은 + SWING_BALANCE_FULL_PAGE_ROWS 다(경로마다 기준이 다르면 같은 계좌를 다르게 해석한다). + """ + rows = int(getattr(settings, "SWING_BALANCE_FULL_PAGE_ROWS", 20) or 0) + return rows > 0 and int(n_rows) >= rows + + +def _holding_unknown_v2(holdings: dict | None, complete: bool, stock_code: str) -> bool: + """[R3-1] 이 종목의 실보유를 '판정 불가'로 봐야 하는가. + + 조회 실패(None)이거나, 응답에 없는데 페이지가 가득 찬 경우(다음 페이지에 있을 수 있음). + 응답에 있는 종목은 페이지가 가득 차도 수량을 신뢰한다(같은 종목 행은 연속돼 나온다는 전제 — + 추정, KIS 응답 정렬 규약 미확인). + """ + if holdings is None: + return True + return (not complete) and (stock_code not in holdings) + + async def _fetch_kis_holdings_v2_impl() -> dict[str, dict] | None: """[H3/M-A] KIS 계좌 실보유 {종목코드: {"hldg": 보유수량, "sellable": 주문가능수량}}. 조회 실패면 None (warning). 빈 목록은 "보유 0"(정상 응답)이다. `ord_psbl_qty` 가 없으면 주문가능수량 = 보유수량으로 본다. 주문가능수량에는 대기 중인 매도 주문분이 이미 빠져 있다. + + [R3-1 2026-10-09] 첫 페이지가 가득 차(응답 밖 종목 누락 가능) 완전성을 보장할 수 없으면 + **None(실보유 미확인)** 을 돌려준다. 호출부가 '응답에 없음 = 0주'로 해석해 고아 정리·불명 + 매도 접수 오판·불명 매수 미접수 오판을 하던 결함을 막는 보수적 계약이다. 종목별로 판정할 수 + 있는 exit_service 내부 호출부는 _fetch_kis_holdings_v3_impl 을 쓴다. + """ + holdings, complete = await _fetch_kis_holdings_v3_impl() + if holdings is not None and not complete: + _error_throttled( + "dt_exit:holdings_page_full", + f"[DayExit] KIS 잔고 첫 페이지가 가득 참(≥{int(getattr(settings, 'SWING_BALANCE_FULL_PAGE_ROWS', 20) or 0)}행) " + f"— 응답 밖 종목 누락 가능, 실보유 미확인으로 처리(get_balance 연속조회 미구현, CLAUDE.md §8.5-37)", + ) + return None + return holdings + + +async def _fetch_kis_holdings_v3_impl() -> tuple[dict[str, dict] | None, bool]: + """[R3-1] (_fetch_kis_holdings_v2_impl 과 같은 dict | None, 완전성). + + 완전성 False = 첫 페이지가 가득 차 응답 밖 종목이 있을 수 있다. 이때 응답에 **없는** 종목은 + 0주가 아니라 '판정 불가'로 다뤄야 한다(_holding_unknown_v2). """ try: bal = await asyncio.wait_for( @@ -1792,20 +3388,20 @@ async def _fetch_kis_holdings_v2_impl() -> dict[str, dict] | None: ) except asyncio.TimeoutError: logger.warning(f"[DayExit] KIS 잔고 조회 {settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC:.0f}초 초과 — 실보유 미확인") - return None + return None, False except Exception as e: logger.warning(f"[DayExit] KIS 잔고 조회 실패 — 실보유 미확인: {e}") - return None + return None, False if not isinstance(bal, dict): logger.warning(f"[DayExit] KIS 잔고 응답 형식 이상({type(bal).__name__}) — 실보유 미확인") - return None + return None, False if bal.get("rt_cd") not in (None, "0"): logger.warning(f"[DayExit] KIS 잔고 조회 오류 응답: {bal.get('msg1')} — 실보유 미확인") - return None + return None, False rows = bal.get("output1") if not isinstance(rows, list): logger.warning("[DayExit] KIS 잔고 응답에 output1 없음 — 실보유 미확인") - return None + return None, False out: dict[str, dict] = {} for h in rows: if not isinstance(h, dict): @@ -1820,11 +3416,11 @@ async def _fetch_kis_holdings_v2_impl() -> dict[str, dict] | None: sq = max(0, min(hq, int(float(h.get("ord_psbl_qty") or 0)))) except (TypeError, ValueError): logger.warning(f"[DayExit] KIS 잔고 수량 파싱 실패 ({code}) — 실보유 미확인") - return None + return None, False cur = out.setdefault(code, {"hldg": 0, "sellable": 0}) cur["hldg"] += hq cur["sellable"] += sq - return out + return out, not _balance_rows_page_full_v2(len(rows)) async def force_close_all(reason: str = "강제 청산") -> list[dict]: @@ -1847,11 +3443,18 @@ async def force_close_all(reason: str = "강제 청산") -> list[dict]: results = [] failed_codes: list[str] = [] codes = list(day_trading_state.positions.keys()) + stale_skipped = 0 for stock_code in codes: pos = day_trading_state.get_position(stock_code) if not pos: continue + # [R1-4] 전일 포지션은 일반 경로(_execute_exit, count_daily_pnl=True)로 팔지 않는다. + # 아래에서 close_stale_positions 로 넘긴다(실보유·타전략 차감, 당일 손익 제외). + if _is_stale_position(pos): + _warn_stale_in_general_path(stock_code, f"강제 청산({reason})") + stale_skipped += 1 + continue # [H-B] 진행 중 청산 주문이 있으면 건너뛴다. 진행 중 표식은 shield 작업의 finally 가 # 해제하므로(결과 불명이면 DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC 뒤) 다음 EOD 사이클(30초)에 # 남은 포지션을 다시 잡는다 — 영구 skip 되지 않는다. @@ -1871,6 +3474,10 @@ async def force_close_all(reason: str = "강제 청산") -> list[dict]: result = await _execute_exit(stock_code, pos, price, reason) if result: results.append(result) + else: + # [R2-5] 예외 없이 None(KIS 거부·결과 불명 보류 등)도 실패로 집계해 재시도·경보한다. + logger.error(f"[DayExit] 강제 청산 미체결 반환 ({stock_code}) — 재시도 대상") + failed_codes.append(stock_code) await asyncio.sleep(0.3) # KIS API 레이트 리밋 @@ -1885,7 +3492,10 @@ async def force_close_all(reason: str = "강제 청산") -> list[dict]: still_failed: list[str] = [] for stock_code in failed_codes: pos = day_trading_state.get_position(stock_code) - if not pos or day_trading_state.is_exit_in_progress(stock_code): + if not pos: + continue + if day_trading_state.is_exit_in_progress(stock_code): + # 결과 불명 보류 등 — 이번 회차는 건드리지 않는다(다음 사이클·장 마감 후 잔존 점검이 잡는다) continue try: price_resp = await korea_investment_api.get_current_price(stock_code) @@ -1897,12 +3507,145 @@ async def force_close_all(reason: str = "강제 청산") -> list[dict]: result = await _execute_exit(stock_code, pos, price, reason) if result: results.append(result) + elif day_trading_state.get_position(stock_code): + logger.critical(f"[DayExit] 강제 청산 재시도 미체결 반환 ({stock_code}) — 포지션 미청산 위험!") + still_failed.append(stock_code) await asyncio.sleep(0.3) except Exception as e: logger.critical(f"[DayExit] 강제 청산 재시도 실패 ({stock_code}): {e} — 포지션 미청산 위험!", exc_info=True) still_failed.append(stock_code) if still_failed: logger.critical(f"[DayExit] 최종 미청산 종목: {still_failed} — 수동 청산 필요!") + # [R2-5] critical 로그만으로는 15:30 이후 오버나이트 보유를 아무도 모른다 → 텔레그램. + _desc = ", ".join( + f"{c}({int((day_trading_state.get_position(c) or {}).get('quantity', 0) or 0)}주)" + for c in still_failed + ) + _alert_once_v2( + "DT_EOD", f"force_close:{reason}:{','.join(sorted(still_failed))}", + f"[데이트레이딩] '{reason}' 재시도 후에도 미청산: {_desc} — 다음 사이클에 재시도하지만 " + f"장 마감(15:30) 전에 계좌를 확인하고 필요하면 수동 청산하세요.", + ) + + # [R2-5] EOD 구간이면 장 마감 직후 잔존 DT 포지션 점검을 하루 1회 예약한다. + if is_eod_flat_time(): + _schedule_post_close_residual_check_impl() + + if stale_skipped: + try: + results.extend(await close_stale_positions() or []) + except Exception as e: + logger.error(f"[DayExit] 강제 청산 중 전일 포지션 청산 실패: {e} — 수동 확인 필요", exc_info=True) logger.info(f"[DayExit] 강제 청산 완료: {len(results)}건 ({reason})") return results + + +# ───────────────────────────────────────────────────────────────────────────── +# [R2-5 2026-10-09] EOD 미청산 경보 +# ───────────────────────────────────────────────────────────────────────────── +# (날짜, tag:key) — 하루 1회만 텔레그램 +_once_alerted: set[tuple[str, str]] = set() +# 장 마감 후 잔존 점검을 예약한 날짜 +_post_close_check_scheduled: set[str] = set() +_post_close_tasks: set = set() + + +def _alert_once_v2(tag: str, key: str, msg: str) -> None: + """logger.critical + 텔레그램 notify_system_warning(tag)(하루 1회/키). 발송 실패는 error 로그만.""" + logger.critical(f"[DayExit] 🚨 {msg}") + _k = (_today_key(), f"{tag}:{key}") + for k in [k for k in _once_alerted if k[0] != _k[0]]: + _once_alerted.discard(k) + if _k in _once_alerted: + return + _once_alerted.add(_k) + try: + from app.services.telegram_notifier import telegram_notifier + _t = asyncio.get_running_loop().create_task(asyncio.wait_for( + telegram_notifier.notify_system_warning(tag, msg), timeout=10.0, + )) + _alert_tasks.add(_t) + _t.add_done_callback(_alert_tasks.discard) + except Exception as e: + logger.error(f"[DayExit] {tag} 경보 발송 실패({key}): {e}") + + +def check_post_close_residual_v2() -> list[str]: + """장 마감 후 인메모리 DT 포지션이 남아 있으면 텔레그램 경보(DT_EOD). 잔존 종목 코드 반환. + + §7 'EOD 전량 청산' 이 지켜지지 않은 상태 — 다음 영업일 close_stale_positions 까지 손절 없이 + 오버나이트로 남는다. 전일(stale) 포지션도 포함해 센다. + """ + residual = sorted(day_trading_state.positions.keys()) + if not residual: + logger.info("[DayExit] 장 마감 후 잔존 DT 포지션 없음 — EOD 전량 청산 확인") + return [] + _desc = ", ".join( + f"{c}({int((day_trading_state.positions.get(c) or {}).get('quantity', 0) or 0)}주" + f"{', 청산 진행/불명' if day_trading_state.is_exit_in_progress(c) else ''})" + for c in residual + ) + _alert_once_v2( + "DT_EOD", f"post_close:{','.join(residual)}", + f"[데이트레이딩] 장 마감 후 미청산 DT 포지션 {len(residual)}종목: {_desc} — 오버나이트 보유 중. " + f"다음 영업일 장중 첫 사이클에 전일 포지션 청산이 시도됩니다. 계좌를 확인하세요.", + ) + return residual + + +# [R3-2] 종가 재확인 1회 조회의 여유 시간(초) — fetch_fill_v2 왕복 상한 추정(config 미편입) +_AUCTION_RECHECK_FETCH_SLACK_SEC = 30.0 + + +def _auction_recheck_wait_cap_impl() -> float: + """[R3-2] 진행 중인 종가 재확인 태스크를 기다릴 최대 초 — 재확인 횟수×(간격+조회 여유)+여유.""" + _, _attempts, _interval = _auction_recheck_params_impl() + return float(_attempts) * (float(_interval) + _AUCTION_RECHECK_FETCH_SLACK_SEC) + _AUCTION_RECHECK_FETCH_SLACK_SEC + + +async def _await_auction_rechecks_impl() -> None: + """[R3-2] 진행 중인 종가 단일가 매도 재확인 태스크가 끝날 때까지 대기(상한 있음). + + 상한을 넘기면 error 를 남기고 점검을 진행한다(남은 재확인이 나중에 복원하면 _on_unfilled 가 + 장 마감 후 복원 경보를 직접 보낸다 — 이중 안전). + """ + pending = [t for t in list(_auction_fill_tasks) if not t.done()] + if not pending: + return + _cap = _auction_recheck_wait_cap_impl() + _done, _not_done = await asyncio.wait(pending, timeout=_cap) + if _not_done: + logger.error( + f"[DayExit] 장 마감 후 잔존 점검: 종가 매도 재확인 {len(_not_done)}건이 {_cap:.0f}초 안에 끝나지 않음 " + f"— 점검을 진행한다(이후 0체결 복원 시 별도 경보)" + ) + + +def _schedule_post_close_residual_check_impl() -> None: + """EOD 구간 첫 호출에서 '장 마감 + DAY_TRADE_FILL_AUCTION_RECHECK_DELAY_SEC' 뒤 잔존 점검 1회 예약.""" + _day = _today_key() + if _day in _post_close_check_scheduled: + return + for d in [d for d in _post_close_check_scheduled if d != _day]: + _post_close_check_scheduled.discard(d) + + async def _run() -> None: + await asyncio.sleep(_seconds_until_auction_recheck_impl(_kst_now())) + # [R3-2] 종가 단일가 매도 재확인(_auction_fill_tasks)도 같은 시각에 깨어나 KIS 조회를 마친 뒤에야 + # 0체결·부분체결 포지션을 되살린다. 그보다 먼저 점검하면 '잔존 없음'을 확정해 버린다 → + # 진행 중인 재확인이 끝날 때까지(상한: 재확인 횟수×(간격+조회 여유)+여유) 기다린 뒤 점검한다. + await _await_auction_rechecks_impl() + try: + check_post_close_residual_v2() + except Exception as e: + logger.error(f"[DayExit] 장 마감 후 잔존 점검 실패: {e} — 수동 확인 필요", exc_info=True) + + try: + task = asyncio.get_running_loop().create_task(_run()) + except RuntimeError as e: + logger.error(f"[DayExit] 장 마감 후 잔존 점검 예약 실패: {e}") + return + _post_close_check_scheduled.add(_day) + _post_close_tasks.add(task) + task.add_done_callback(_post_close_tasks.discard) diff --git a/backend/app/services/daytrading/fill_reconciler.py b/backend/app/services/daytrading/fill_reconciler.py index 8c4d63c..10ba8f4 100644 --- a/backend/app/services/daytrading/fill_reconciler.py +++ b/backend/app/services/daytrading/fill_reconciler.py @@ -206,6 +206,17 @@ def is_terminal_fill(info: dict, ordered_qty: int) -> bool: async def _poll_fill_v2(stock_code: str, order_id: str, ordered_qty: int) -> Optional[dict]: """종결될 때까지(또는 시도 소진까지) 폴링. 마지막으로 본 행 정보를 돌려준다.""" + return await _poll_fill_v2_impl(stock_code, order_id, ordered_qty, None) + + +async def _poll_fill_v2_impl( + stock_code: str, order_id: str, ordered_qty: int, seen: Optional[dict], +) -> Optional[dict]: + """`_poll_fill_v2` 본체. `seen` 이 주어지면 마지막으로 본 행을 `seen["last"]` 에도 남긴다. + + [R1 2026-10-09] fetch_fill_v2 의 wait_for 가 시간초과로 이 코루틴을 취소하면 반환값이 + 사라져, 이미 본 부분체결 행까지 '행 미확인'과 같은 None 이 됐다. seen 으로 그 행을 살린다. + """ attempts = max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)) interval = max(0.0, float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC)) await asyncio.sleep(max(0.0, float(settings.DAY_TRADE_FILL_CHECK_DELAY_SEC))) @@ -215,6 +226,8 @@ async def _poll_fill_v2(stock_code: str, order_id: str, ordered_qty: int) -> Opt info = parse_fill_row_v2(rows, order_id) if info is not None: last = info + if seen is not None: + seen["last"] = info if is_terminal_fill(info, ordered_qty): return info if i < attempts - 1: @@ -233,16 +246,26 @@ async def fetch_fill_v2( logger.warning(f"[DTFill] {stock_code} {side}: 주문번호 없음 — 신호가 기록 유지") return None timeout = float(settings.DAY_TRADE_FILL_CHECK_TIMEOUT_SEC) + _seen: dict = {} try: info = await asyncio.wait_for( - _poll_fill_v2(stock_code, order_id, ordered_qty), timeout=timeout, + _poll_fill_v2_impl(stock_code, order_id, ordered_qty, _seen), timeout=timeout, ) except asyncio.TimeoutError: + _last = _seen.get("last") + if _last is None: + logger.warning( + f"[DTFill] {stock_code} {side} 체결 조회 시간초과({timeout:.0f}초) " + f"order_id={order_id} — 신호가 기록 유지" + ) + return None + # [R1 2026-10-09] 시간초과 전에 본 행이 있으면 버리지 않는다(시도 소진 시와 같은 의미: + # 미종결이면 terminal=False 로 넘어가 호출부가 수량 보정을 보류한다). logger.warning( f"[DTFill] {stock_code} {side} 체결 조회 시간초과({timeout:.0f}초) " - f"order_id={order_id} — 신호가 기록 유지" + f"order_id={order_id} — 직전 확인 행(체결 {int(_last.get('filled_qty', 0) or 0)}주) 사용" ) - return None + info = _last except Exception as e: logger.warning( f"[DTFill] {stock_code} {side} 체결 조회 실패 order_id={order_id}: " @@ -275,7 +298,9 @@ def schedule_v2( return None async def _run() -> None: - info = await fetch_fill_v2(stock_code, order_id, side, ordered_qty) + # [R1 2026-10-09] 즉시 확인(≈6초)에서 종결을 못 보면 지연 재확인까지 한 뒤 **1회만** 넘긴다 + # (on_result 는 기록 손익 대비 차액을 더하므로 두 번 부르면 이중 보정된다). + info = await fetch_fill_with_late_recheck_v2(stock_code, order_id, side, ordered_qty) if info is None: return try: @@ -295,3 +320,92 @@ def schedule_v2( _pending_tasks.add(task) task.add_done_callback(_pending_tasks.discard) return task + + +# ── [R1 2026-10-09] 지연 재확인 ─────────────────────────────────────────────── +# 운영 실측(trade_history daytrading, 2026-10-06~08): SELL 8/18·BUY 3/19 의 slippage 가 NULL +# = 체결 미확인(신호가 기록 유지). 즉시 확인 창은 DELAY 2 + INTERVAL 2×2 ≈ 6초(+조회 왕복)로, +# 모의 서버 체결 지연·조회 반영 지연·rate-limit 으로 행이 늦게 보이면 그대로 놓친다(원인 추정 — +# 운영 로그 미확인). 놓치면 daily_pnl(-3% 한도)·연패 판정·원장이 신호가 기준으로 남는다. +# 즉시 확인이 종결을 못 보면 간격을 넓혀 몇 번 더 본다. 전략 파라미터가 아니라 조회 정책이다. +# 기본값: 시도 4회, 간격 = 즉시 확인 간격 × 5(운영 2초 → 10초), 호출당 상한 = 즉시 확인 전체 상한. +# config 키(DAY_TRADE_FILL_LATE_RECHECK_ATTEMPTS / _INTERVAL_SEC)가 있으면 그 값을 쓴다. +_LATE_RECHECK_DEFAULT_ATTEMPTS = 4 +_LATE_RECHECK_INTERVAL_FACTOR = 5.0 + + +def _late_recheck_params_impl() -> tuple[int, float, float]: + """(시도 횟수, 간격 초, 호출당 상한 초). 음수·잘못된 값은 0 으로 막는다.""" + try: + attempts = int(getattr(settings, "DAY_TRADE_FILL_LATE_RECHECK_ATTEMPTS", + _LATE_RECHECK_DEFAULT_ATTEMPTS)) + except (TypeError, ValueError): + attempts = _LATE_RECHECK_DEFAULT_ATTEMPTS + _iv = getattr(settings, "DAY_TRADE_FILL_LATE_RECHECK_INTERVAL_SEC", None) + try: + interval = (float(_iv) if _iv is not None + else float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC) * _LATE_RECHECK_INTERVAL_FACTOR) + except (TypeError, ValueError): + interval = float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC) * _LATE_RECHECK_INTERVAL_FACTOR + per_call = max(0.0, float(settings.DAY_TRADE_FILL_CHECK_TIMEOUT_SEC)) + return max(0, attempts), max(0.0, interval), per_call + + +async def fetch_fill_with_late_recheck_v2( + stock_code: str, order_id: str, side: str, ordered_qty: int, +) -> Optional[dict]: + """fetch_fill_v2 + (종결 미확인이면) 지연 재확인. 반환 형식은 fetch_fill_v2 와 같다. + + - 즉시 확인이 종결이면 그대로 반환(추가 조회 없음). + - 아니면 `_late_recheck_params_impl()` 만큼 더 조회한다. 조회 1회의 예외·시간초과는 그 회차만 + 건너뛴다(warning). 종결 행을 보면 즉시 반환, 끝까지 못 보면 가장 최근에 본 행 + (terminal=False) — 없으면 None. + - 로그: 지연 재확인으로 확정되면 info, 최종 미확인이면 warning 1건 + ('최종 체결 미확인' — 운영 집계용. 즉시 확인 단계의 warning 은 그대로 남는다). + 종가 단일가(15:20~15:30) 매도는 exit_service 가 장 마감 후 재확인을 따로 하므로 이 함수를 쓰지 않는다. + """ + info = await fetch_fill_v2(stock_code, order_id, side, ordered_qty) + if not order_id: + return info + if info is not None and info.get("terminal"): + return info + attempts, interval, per_call = _late_recheck_params_impl() + if attempts <= 0: + return info + last = info + for i in range(attempts): + await asyncio.sleep(interval) + try: + rows = await asyncio.wait_for( + korea_investment_api.get_todays_orders(stock_code), timeout=per_call, + ) + except asyncio.TimeoutError: + logger.warning( + f"[DTFill] {stock_code} {side} 지연 재확인 {i + 1}/{attempts} 시간초과({per_call:.0f}초) " + f"order_id={order_id}" + ) + continue + except Exception as e: + logger.warning( + f"[DTFill] {stock_code} {side} 지연 재확인 {i + 1}/{attempts} 실패 order_id={order_id}: " + f"{type(e).__name__}: {e}" + ) + continue + row = parse_fill_row_v2(rows, order_id) + if row is None: + continue + last = dict(row) + last["terminal"] = is_terminal_fill(row, ordered_qty) + if last["terminal"]: + logger.info( + f"[DTFill] {stock_code} {side} 지연 재확인 {i + 1}/{attempts}회차에 종결 확인 " + f"(체결 {int(last.get('filled_qty', 0) or 0)}/{ordered_qty}주) order_id={order_id}" + ) + return last + logger.warning( + f"[DTFill] {stock_code} {side} 최종 체결 미확인(즉시 확인 + 지연 재확인 {attempts}회, " + f"간격 {interval:.0f}초) order_id={order_id} — " + + ("주문 행 없음, 신호가 기록 유지" if last is None + else f"미종결 행 사용(체결 {int(last.get('filled_qty', 0) or 0)}/{ordered_qty}주)") + ) + return last diff --git a/backend/app/services/daytrading/position_manager.py b/backend/app/services/daytrading/position_manager.py index ac3bc67..20f3e32 100644 --- a/backend/app/services/daytrading/position_manager.py +++ b/backend/app/services/daytrading/position_manager.py @@ -37,6 +37,18 @@ logger = logging.getLogger(__name__) # 한국 표준시(KST) 타임존 객체 — 날짜 비교 및 시간 계산에 사용 _KST = pytz.timezone("Asia/Seoul") +# [R2 2026-10-09] 일일 안전장치 원장 재구성 (Redis 복원 실패 시) — 운영 상수(전략 파라미터 아님) +_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC = 15.0 +_DAILY_STATE_REBUILD_RETRY_SEC = 120.0 +_DAILY_STATE_NOTIFY_TIMEOUT_SEC = 10.0 +# 전일·고아 청산의 exit_reason 접두사(daytrading/exit_service.close_stale_positions). +# 이 청산 손익은 당일 실현손익·연패에 넣지 않는다(§7, count_daily_pnl=False) — 재구성도 동일. +# 문자열이 바뀌면 test_dt_daily_state_rebuild_r2_20261009 의 동기화 테스트가 깨진다. +_STALE_EXIT_REASON_PREFIXES = ("전일 미청산 포지션 강제 청산", "DT 고아 잔고 청산") +_DT_STRATEGY_TYPE = "daytrading" +_VOID_STATUSES = frozenset({"CANCELLED", "FAILED", "REJECTED"}) +_daily_state_retry_task: "asyncio.Task | None" = None + def _today_kst() -> date: """현재 KST 기준 날짜 반환. @@ -77,7 +89,21 @@ class DayTradingState: # 마지막으로 조회된 총자산 self.last_total_assets: float = 0.0 # 당일 청산 종목 (재진입 방지) + # ⚠️ 스윙 손절(swing/exit_service)·MDD 등 **타 전략 청산도** 전략 간 당일 쿨다운으로 + # 여기에 들어온다 → "DT 청산 수"로 세면 안 된다(아래 dt_exited_today 사용). self.closed_today: set[str] = set() + # [2026-10-08] 당일 **DT 포지션이 전량 청산된** 종목 (표시·집계 전용, 재진입 판정에 쓰지 않음) + # DT 엔진 카드 '청산 N' 에 스윙 손절 종목이 섞이던 문제(10/08: DT 6 + 스윙 2 = 8). + self.dt_exited_today: set[str] = set() + # [R2 하드닝 2026-10-09] 당일 DT 전량 청산(dt_exited_today) 뒤 **보유가 재개된** 종목. + # 전량 매도가 0체결·부분체결로 종결돼 exit_service._on_unfilled 가 포지션을 되살린 경우 + # (add_position 이 dt_exited_today 종목을 받는 유일한 경로 — DT 는 당일 재진입 금지)에 + # 표시한다. 기동 복원 검증·장중 복원이 이 종목을 '청산 완료'로 보지 않게 한다. + # remove_position(다시 전량 청산)에서 해제된다. 재진입 차단(closed_today)과 무관. + self.dt_reheld_today: set[str] = set() + # [R2 하드닝] 구버전 Redis 페이로드(dt_exited_today 키 없음)로 복원된 closed_today — + # DT 청산과 재진입 차단을 구분할 수 없으므로 복원 판정에서만 '청산 완료'로 간주(보수적). + self.restore_block_legacy: set[str] = set() # 당일 연속 손실 횟수 (쿨다운 트리거) self.loss_streak_today: int = 0 # 마지막 초기화 날짜 @@ -96,6 +122,14 @@ class DayTradingState: # — 주문은 진행 중인데 인메모리 수량이 아직 그대로라 같은 물량을 다시 파는 것을 막는다. # 날짜 초기화(reset_if_new_day)로 지우지 않는다(진행 중 주문의 보호가 풀린다). self.exits_in_progress: set[str] = set() + # [R2 2026-10-09] 일일 안전장치 **미검증** 상태 — Redis 복원 실패 + TradeHistory 재구성 + # 실패(또는 손실 상태인데 총자산 분모 미확보)면 True. is_cooldown_active() 가 True 를 + # 반환해 **DT 신규 진입만** 막는다(청산 경로는 이 값을 보지 않는다 — §8.1-11). + # 재구성 재시도가 성공하거나 날짜가 바뀌면(reset_if_new_day) 해제된다. + self.daily_state_unverified: bool = False + self.daily_state_unverified_reason: str = "" + # 일일 안전장치의 출처: "" | "redis" | "trade_history" (표시·진단용) + self.daily_state_source: str = "" def reset_if_new_day(self) -> bool: """날짜가 바뀌면 일일 상태 초기화. @@ -113,8 +147,15 @@ class DayTradingState: self.daily_pnl = 0.0 self.last_total_assets = 0.0 self.closed_today = set() + self.dt_exited_today = set() + self.dt_reheld_today = set() + self.restore_block_legacy = set() self.loss_streak_today = 0 self.last_reset_date = today + # [R2] 미검증 차단은 **그날의** 상태에 대한 것 — 새 날은 0 에서 시작하는 것이 정상 + self.daily_state_unverified = False + self.daily_state_unverified_reason = "" + self.daily_state_source = "" self.portfolio_summary_cache = {} self.portfolio_summary_ts = None self.portfolio_cache_dirty = True @@ -165,6 +206,11 @@ class DayTradingState: """ self.positions[stock_code] = data self.portfolio_cache_dirty = True + # [R2 하드닝] 당일 DT 전량 청산 기록이 있는 종목이 다시 들어오면 = 매도 미체결 종결 복원 + # (closed_today 로 신규 진입은 막혀 있다). 재기동 복원 검증이 '청산 완료'로 오판하지 않게 표시. + if stock_code in self.dt_exited_today or stock_code in self.restore_block_legacy: + self.dt_reheld_today.add(stock_code) + self.restore_block_legacy.discard(stock_code) def remove_position(self, stock_code: str) -> dict | None: """포지션 제거 및 반환. @@ -181,9 +227,31 @@ class DayTradingState: pos = self.positions.pop(stock_code, None) if pos is not None: self.closed_today.add(stock_code) + self.dt_exited_today.add(stock_code) + self.dt_reheld_today.discard(stock_code) self.portfolio_cache_dirty = True return pos + def restore_block_reason_v2(self, stock_code: str) -> str | None: + """[R2 하드닝 2026-10-09] 당일 DT active 행을 인메모리로 **되살리면 안 되는** 사유(없으면 None). + + closed_today 는 **재진입 차단** 집합이다 — 불명 매수 등록(entry_service)·스윙 손절(전략 간 + 쿨다운)·매도 0체결 종결 뒤에도 남아 있어 '청산 완료'의 근거가 될 수 없다. 예전에는 + closed_today 만으로 복원을 막아 실제 보유 중인 당일 DT 포지션이 손절·EOD 관리 밖으로 + 빠졌다(오버나이트). 이제 '청산 완료'는 DT 전량 청산(dt_exited_today) 기준이고, 그 뒤 + 보유가 재개된 종목(dt_reheld_today)은 제외한다. 나머지는 호출부가 KIS 실보유 − 타전략 + 수량(§3.5) 규칙으로 판정한다(이 메서드는 그 판정을 대신하지 않는다). + """ + if stock_code in self.exits_in_progress: + return "청산 주문 진행/결과 불명 보류 중(exits_in_progress)" + if stock_code in self.dt_reheld_today: + return None + if stock_code in self.dt_exited_today: + return "당일 DT 전량 청산 기록(dt_exited_today) 있음" + if stock_code in self.restore_block_legacy: + return "구버전 일일상태(dt_exited_today 없음) — closed_today 를 청산 근거로 간주" + return None + def update_pnl(self, realized: float, is_full_close: bool = True) -> None: """실현 손익 누적 및 연속 손실 카운트 업데이트. @@ -258,9 +326,17 @@ class DayTradingState: CLAUDE.md 7.3: 당일 3연패 시 신규 진입 차단. + [R2 2026-10-09] 일일 안전장치가 미검증(daily_state_unverified)이어도 True — + Redis·TradeHistory 어느 쪽으로도 당일 손익·연패·재진입 차단을 복원하지 못한 상태에서 + 0 부터 매매를 재개하면 -3% 한도·3연패 쿨다운이 풀린다(fail-open). 진입 게이트 + (entry_service.scan_and_enter / scheduler._entry_blocked_by_exit_results)가 이 + 메서드를 쓰므로 시그니처를 바꾸지 않고 여기서 함께 막는다. 청산 경로는 무관. + Returns: bool: 쿨다운 활성화 여부 """ + if self.daily_state_unverified: + return True return self.loss_streak_today >= 3 def has_orb(self, stock_code: str) -> bool: @@ -349,6 +425,11 @@ class DayTradingState: "daily_pnl": float(self.daily_pnl), "loss_streak_today": int(self.loss_streak_today), "closed_today": sorted(self.closed_today), + # [2026-10-08] DT 전용 청산 집합(표시용) — 없으면 구버전 페이로드 + "dt_exited_today": sorted(self.dt_exited_today), + # [R2 하드닝] 청산 뒤 보유 재개(매도 미체결 종결 복원) — 재기동 복원 검증용 + "dt_reheld_today": sorted(self.dt_reheld_today), + "restore_block_legacy": sorted(self.restore_block_legacy), # [C2 2026-09-28] 손실률 분모. 이 값이 0 이면 # is_daily_loss_exceeded() 가 total_assets<=0 에서 **무조건 False** 라 # 재기동 직후 첫 scan_and_enter() 가 일일 -3% 한도를 통과해버린다 @@ -400,6 +481,21 @@ class DayTradingState: self.daily_pnl = _pnl if not self.daily_pnl else min(self.daily_pnl, _pnl) self.loss_streak_today = max(self.loss_streak_today, _streak) self.closed_today |= _closed + # [2026-10-08] 구버전 페이로드(키 없음)면 표시용 집합은 비워 둔다 — 스윙 쿨다운 + # 종목이 섞인 closed_today 로 대체하면 같은 오집계가 되살아난다. + self.dt_exited_today |= { + str(c) for c in (data.get("dt_exited_today") or []) if c + } + self.dt_reheld_today |= { + str(c) for c in (data.get("dt_reheld_today") or []) if c + } + self.restore_block_legacy |= { + str(c) for c in (data.get("restore_block_legacy") or []) if c + } + if "dt_exited_today" not in data: + # [R2 하드닝] 구버전 페이로드 — DT 청산과 재진입 차단을 구분할 수 없다. 복원 판정에서만 + # closed_today 를 청산 근거로 쓴다(재매도 방지 쪽, 구 동작과 동일). + self.restore_block_legacy |= _closed # [C2] 분모는 **작은 쪽이 보수적**이다: pnl<0 일 때 (pnl/assets) 가 더 음수가 # 되어 -3% 한도가 더 빨리 걸린다. 단 0/음수는 게이트를 통째로 무력화하므로 # 후보에서 제외하고, 양수가 하나도 없으면 현재값(0.0)을 그대로 둔다. @@ -420,6 +516,152 @@ class DayTradingState: ) return False + async def rebuild_daily_state_after_redis_miss_v2(self, session_factory=None) -> bool: + """[R2 2026-10-09] Redis 복원 실패 시 당일 DT 원장(TradeHistory)으로 일일 안전장치 재구성. + + ``load_daily_state()`` 는 Redis 단일 소스라, 장중에 Redis 재기동(RDB 간격 내 유실)· + ``volatile-lru`` 축출(이 키는 setex TTL 을 가진다)·저장 실패 뒤 백엔드를 재기동하면 + daily_pnl=0 / 연패 0 / closed_today=∅ 로 시작해 **이미 -3% 한도로 중단된 날에 매매가 + 재개**됐다(fail-open, warning 만). 당일 DT SELL 원장(net_profit_loss)은 DB 에 있으므로 + 그것으로 재구성한다. + + - 성공(행 0건 포함 — 당일 매매가 없었다는 검증된 사실): 보수적 병합 후 Redis 재저장, True. + - 실패: ``daily_state_unverified=True`` → is_cooldown_active() 가 DT **신규 진입만** + 차단, error 로그 + 텔레그램, 백그라운드 재시도 예약. False. + - 손실 상태인데 총자산 분모(last_total_assets)를 확보하지 못해도 미검증으로 둔다 — + 분모 0 이면 is_daily_loss_exceeded() 가 무조건 False 라 -3% 한도가 무력화된다. + + [§3.1 / §3.2 ③ — 기동 시 1회(+실패 시 재시도), 읽기 전용] SELECT 만 수행하고 + commit()/add() 가 없다. 이 메서드는 쓰기를 하지 않는다 — 추가하지 말 것. + 이 메서드는 예외를 던지지 않는다(차단 판정은 try 밖에서 한다, §4). + + Args: + session_factory: 테스트 주입용 세션 팩토리(기본 AsyncSessionLocal). + """ + ok, err = await self._rebuild_daily_state_attempt_impl(session_factory) + if ok: + return True + await self._mark_daily_state_unverified_impl(err) + self._schedule_daily_state_rebuild_retry_impl(session_factory) + return False + + async def _rebuild_daily_state_attempt_impl(self, session_factory=None) -> tuple[bool, str]: + """재구성 1회 시도. (검증완료 여부, 실패 사유) — 예외를 던지지 않는다.""" + today = _today_kst() + rows = None + err = "" + try: + rows = await asyncio.wait_for( + _fetch_today_trade_rows_impl(today, session_factory), + timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC, + ) + except asyncio.TimeoutError: + err = f"TradeHistory 조회 {_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC:.0f}초 초과" + except Exception as e: + err = f"TradeHistory 조회 실패: {type(e).__name__}: {e}" + if rows is None: + logger.warning(f"[DayTrading] ⚠️ 일일 안전장치 원장 재구성 실패 — {err}") + return False, err or "TradeHistory 조회 결과 없음" + + rebuilt = _rebuild_daily_state_from_rows(rows) + async with self.trade_lock: + # 같은 날 재시도(진입 차단 중에도 청산은 돌아 인메모리 손익이 쌓인다) 시 이중 집계를 + # 피하려고 합산하지 않는다 — 원장 합계가 이미 그 청산을 포함한다. 둘 중 **더 보수적인** + # 쪽(손익 min, 연패 max, 집합 합집합)을 취한다(load_daily_state 와 같은 규칙). + _pnl = float(rebuilt["daily_pnl"]) + self.daily_pnl = _pnl if not self.daily_pnl else min(self.daily_pnl, _pnl) + self.loss_streak_today = max(self.loss_streak_today, int(rebuilt["loss_streak_today"])) + self.closed_today |= set(rebuilt["closed_today"]) + self.dt_exited_today |= set(rebuilt["dt_exited_today"]) + self.dt_reheld_today |= set(rebuilt.get("dt_reheld_today") or ()) + _need_assets = self.daily_pnl < 0 and float(self.last_total_assets or 0) <= 0 + + if _need_assets: + # 분모 확보(청산 경로와 같은 함수 — KIS 잔고 → 마지막 값). 실패해도 예외를 던지지 않는다. + try: + from app.services.daytrading.exit_service import _ensure_total_assets_for_exit_impl + await asyncio.wait_for( + _ensure_total_assets_for_exit_impl(), timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC, + ) + except asyncio.TimeoutError: + logger.warning("[DayTrading] 일일 안전장치 재구성 후 총자산 조회 시간 초과") + except Exception as e: + logger.warning(f"[DayTrading] 일일 안전장치 재구성 후 총자산 조회 실패: {e}") + # 판정은 try 밖 (§4) + if self.daily_pnl < 0 and float(self.last_total_assets or 0) <= 0: + err = ( + f"당일 실현손익 {self.daily_pnl:+,.0f}원(손실)인데 총자산 분모 미확보 — " + f"일일 손실한도 판정 불가" + ) + logger.warning(f"[DayTrading] ⚠️ {err}") + return False, err + + self.daily_state_source = "trade_history" + _was_blocked = self.daily_state_unverified + self.daily_state_unverified = False + self.daily_state_unverified_reason = "" + logger.warning( + f"[DayTrading] 일일 안전장치 원장(TradeHistory) 재구성: 실현손익={self.daily_pnl:+,.0f}원 " + f"(전일·고아 청산 {rebuilt['stale_excluded']}건 제외), 연패={self.loss_streak_today}, " + f"당일청산={len(self.closed_today)}종목, 원장 {rebuilt['dt_sell_rows']}건" + + (" — 신규 진입 차단 해제" if _was_blocked else "") + ) + try: + await self.save_daily_state() + except Exception as e: + logger.warning(f"[DayTrading] 재구성 상태 Redis 재저장 실패: {e}") + if _was_blocked: + await _notify_daily_state_impl( + "DT 일일 안전장치 원장 재구성 성공 — 신규 진입 차단 해제 " + f"(실현손익 {self.daily_pnl:+,.0f}원, 연패 {self.loss_streak_today})" + ) + return True, "" + + async def _mark_daily_state_unverified_impl(self, reason: str) -> None: + """미검증 상태 표시 + error 로그 + 텔레그램 (예외를 던지지 않는다).""" + _first = not self.daily_state_unverified + self.daily_state_unverified = True + self.daily_state_unverified_reason = reason + logger.error( + f"[DayTrading] 🚨 일일 안전장치 복원 불가(Redis·TradeHistory) — DT 신규 진입 차단 " + f"(청산은 계속). 사유: {reason}" + ) + if _first: + await _notify_daily_state_impl( + "DT 일일 안전장치(일일 손실한도·연패·재진입 차단) 복원 불가 — 당일 DT 신규 진입 차단. " + f"사유: {reason}. 원장 재구성을 {_DAILY_STATE_REBUILD_RETRY_SEC:.0f}초 간격으로 재시도한다." + ) + + def _schedule_daily_state_rebuild_retry_impl(self, session_factory=None) -> None: + """미검증 상태에서 재구성을 주기적으로 재시도(같은 날 한정). 중복 예약하지 않는다.""" + global _daily_state_retry_task + if _daily_state_retry_task is not None and not _daily_state_retry_task.done(): + return + try: + _daily_state_retry_task = asyncio.get_running_loop().create_task( + self._daily_state_rebuild_retry_loop_impl(_today_kst(), session_factory) + ) + except RuntimeError as e: + logger.warning(f"[DayTrading] 일일 안전장치 재구성 재시도 예약 실패: {e}") + + async def _daily_state_rebuild_retry_loop_impl(self, day: date, session_factory=None) -> None: + attempt = 0 + while self.daily_state_unverified: + await asyncio.sleep(_DAILY_STATE_REBUILD_RETRY_SEC) + if _today_kst() != day or self.last_reset_date not in (None, day): + # 날짜가 바뀌면 reset_if_new_day 가 플래그를 내린다 — 전일 재구성은 의미 없음 + return + if not self.daily_state_unverified: + return + attempt += 1 + ok, err = await self._rebuild_daily_state_attempt_impl(session_factory) + if ok: + return + self.daily_state_unverified_reason = err + logger.warning( + f"[DayTrading] 일일 안전장치 재구성 재시도 {attempt}회 실패 — 신규 진입 차단 유지: {err}" + ) + def set_orb(self, stock_code: str, orb: Any) -> None: """ORB(시초가 레인지) 데이터 저장. @@ -484,54 +726,7 @@ class DayTradingState: if trade.stock_code in self.positions: continue - entry_price = float(trade.entry_price) if trade.entry_price else 0 - stop_loss = float(trade.stop_loss) if trade.stop_loss else 0 - target_price = float(trade.take_profit) if trade.take_profit else 0 - - # [FIX] stop_loss/target_price가 0/None으로 복원되면 entry_price 기반 - # 기본값으로 대체 — day_trading_strategy.py의 target_price<=0 즉시매도 방지 - if entry_price > 0 and stop_loss <= 0: - stop_loss = entry_price * (1 - settings.DAY_TRADE_STOP_LOSS_PCT) - logger.warning( - f"[DayTrading] {trade.stock_code} stop_loss 복원값 없음 → " - f"기본값 적용: {stop_loss:.0f}" - ) - if entry_price > 0 and target_price <= 0: - risk = entry_price - stop_loss - target_price = entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER - logger.warning( - f"[DayTrading] {trade.stock_code} target_price 복원값 없음 → " - f"기본값 적용: {target_price:.0f}" - ) - - _restored = { - "stock_name": trade.stock_name or trade.stock_code, - "strategy": trade.strategy_name or "day_trade", - "entry_price": entry_price, - "stop_loss": stop_loss, - "target_price": target_price, - "quantity": trade.quantity or 0, - "highest_price": float(trade.highest_since_entry) if trade.highest_since_entry else ( - float(trade.entry_price) if trade.entry_price else 0 - ), - "entry_time": trade.created_at, - "reason": trade.entry_reason or "", - "order_id": trade.order_id or "", - "restored_from_db": True, - } - # [M1 2026-10-02] 분할매도 이력 복원. exit_service 의 분할매도가 - # remaining_pct(<1.0) 를 ActiveTrade 에 남긴다. 복원 시 이를 보지 않으면 - # target_price 가 원래 목표가로 되살아나 재기동 직후 같은 50% 분할이 - # 재발동한다(잔량의 절반을 또 판다). 인메모리 분할 후 상태와 동일하게 - # 목표가를 제거(inf)하고 partial_sold 를 세운다 → 트레일링만 적용. - if _is_partial_sold_row(trade): - _restored["target_price"] = float("inf") - _restored["partial_sold"] = True - logger.warning( - f"[DayTrading] {trade.stock_code} 분할매도 이력 복원 " - f"(remaining_pct={trade.remaining_pct}) — 목표가 분할 재발동 차단, " - f"트레일링 고점={_restored['highest_price']:.0f}" - ) + _restored = _active_trade_row_to_position(trade) self.positions[trade.stock_code] = _restored restored += 1 logger.info( @@ -550,6 +745,211 @@ class DayTradingState: return restored + async def verify_restored_today_positions_v2( + self, session_factory=None, holdings_fetcher=None, + ) -> dict: + """[R3 2026-10-09] 기동 시 restore_from_db 가 되살린 **당일** DT 포지션 검증. + + restore_from_db 는 closed_today·KIS 실보유를 보지 않고 active 행 수량으로 포지션을 + 되살린다. 전량 청산 직후 close_active_trade 큐 작업이 반영되지 못한 채(DLQ·drain 초과) + 재기동하거나 분할 매도 뒤 잔량 upsert 가 유실되면, 이미 판 물량을 일반 청산 경로가 + 다시 시장가로 판다(같은 종목 스윙 보유분 매도 또는 반복 거부 — §3.5). + 장중 복원 경로(exit_service._restore_lost_today_positions_impl)와 같은 규칙을 적용한다: + - 당일 DT 전량 청산 종목(restore_block_reason_v2: dt_exited_today − dt_reheld_today) → 복원 보류 + [R2 하드닝] closed_today(재진입 차단)만으로는 보류하지 않는다 — 불명 매수 접수 확정· + 매도 0체결 종결 복원·스윙 손절 쿨다운 종목도 들어 있어 실제 보유 포지션이 관리에서 빠졌다. + - KIS 보유 − 비DT active 수량 < 행 수량(또는 수량 ≤ 0) → 복원 보류 + - 판정 불가(잔고·DB 조회 실패) → DAY_TRADE_ORPHAN_DETECT_ENABLED 면 보류 + (close_stale_positions 가 매 사이클 같은 규칙으로 재검증·복원한다), 꺼져 있으면 + 복원 유지(아무도 되살리지 않아 손절 공백이 되므로) + error. + 보류는 인메모리에서만 뺀다(closed_today 에 넣지 않음). 수량·판정불가 보류는 잔고가 맞으면 장중 + 복원이 다시 잡는다. 청산 기록 보류는 장중 복원도 같은 사유로 막으므로 수동 확인 대상이다. + 전일 진입분은 대상이 아니다(close_stale_positions 가 실보유·타전략 차감으로 처리). + + Returns: {"checked": n, "held": [codes], "unverified": bool} + """ + from app.core.config import settings + + today = _today_kst() + held: list[str] = [] + reasons: list[str] = [] + mismatch = False # 실제 불일치(청산 기록·수량 부족) — 판정 불가와 구분해 경보 수준을 정한다 + exit_held = False # 청산 기록으로 보류 — 장중 재복원 경로도 같은 사유로 막는다 + async with self.trade_lock: + cands = { + c: p for c, p in self.positions.items() + if p.get("restored_from_db") and _entry_date(p.get("entry_time")) == today + } + for c in list(cands): + _why = self.restore_block_reason_v2(c) + if _why is not None: + if self.positions.get(c) is cands[c]: + self.positions.pop(c, None) + held.append(c) + reasons.append(f"{c}: {_why}") + mismatch = True + exit_held = True + cands.pop(c) + out = {"checked": len(cands) + len(held), "held": held, "unverified": False} + + if cands: + other_qty = None + try: + other_qty = await asyncio.wait_for( + _fetch_non_dt_active_qty_by_code_impl(list(cands), session_factory), + timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC, + ) + except asyncio.TimeoutError: + logger.warning("[DayTrading] 복원 검증: 비DT 보유 조회 시간 초과") + except Exception as e: + logger.warning(f"[DayTrading] 복원 검증: 비DT 보유 조회 실패: {type(e).__name__}: {e}") + holdings = None + if other_qty is not None: + try: + if holdings_fetcher is None: + from app.services.daytrading.exit_service import ( + _fetch_kis_holdings_v2_impl as holdings_fetcher, + ) + holdings = await holdings_fetcher() + except Exception as e: + logger.warning(f"[DayTrading] 복원 검증: KIS 잔고 조회 실패: {type(e).__name__}: {e}") + holdings = None + + # 판정·보류는 try 밖 (§4 — 가드를 try 안에 두지 않는다) + unverified = other_qty is None or holdings is None + out["unverified"] = unverified + if unverified and not settings.DAY_TRADE_ORPHAN_DETECT_ENABLED: + logger.error( + f"[DayTrading] 🚨 기동 복원 검증 불가(실보유·타전략 미확인) — 장중 재검증 경로" + f"(DAY_TRADE_ORPHAN_DETECT_ENABLED=False)가 없어 복원 유지: {sorted(cands)}. " + f"이미 매도된 물량이면 재매도 위험 — 수동 확인 필요" + ) + else: + async with self.trade_lock: + for c, p in cands.items(): + qty = int(p.get("quantity", 0) or 0) + if unverified: + why = "실보유·타전략 수량 판정 불가 → 장중 close_stale_positions 재검증 대기" + else: + hldg = int((holdings.get(c) or {}).get("hldg", 0) or 0) + oth = int(other_qty.get(c, 0) or 0) + if qty > 0 and hldg - oth >= qty: + continue + why = f"실보유 {hldg}주 − 타전략 {oth}주 = {hldg - oth}주 < 행 수량 {qty}주" + if self.positions.get(c) is p: + self.positions.pop(c, None) + held.append(c) + reasons.append(f"{c}: {why}") + mismatch = mismatch or not unverified + + if held: + msg = ( + f"[DayTrading] 🚨 기동 시 당일 DT 포지션 복원 보류 {len(held)}건 — " + + "; ".join(reasons) + + " (이미 매도된 물량 재매도 방지. 수량·판정불가 보류는 잔고가 맞으면 장중 " + "close_stale_positions 가 재복원" + + ("; 청산 기록 보류는 장중에도 재복원하지 않음 — 실보유가 남아 있으면 수동 청산 필요)" + if exit_held else ")") + ) + if mismatch: + logger.error(msg) + await _notify_daily_state_impl(msg) + else: + logger.warning(msg) + return out + + +def _active_trade_row_to_position(trade) -> dict: + """ActiveTrade(day_trade) 행 → 인메모리 포지션 dict (restore_from_db 와 고아 복원 공용). + + [2026-10-08] restore_from_db 의 행 변환을 그대로 옮겼다(동작 무변경). + """ + from app.core.config import settings + + entry_price = float(trade.entry_price) if trade.entry_price else 0 + stop_loss = float(trade.stop_loss) if trade.stop_loss else 0 + target_price = float(trade.take_profit) if trade.take_profit else 0 + + # [FIX] stop_loss/target_price가 0/None으로 복원되면 entry_price 기반 + # 기본값으로 대체 — day_trading_strategy.py의 target_price<=0 즉시매도 방지 + if entry_price > 0 and stop_loss <= 0: + stop_loss = entry_price * (1 - settings.DAY_TRADE_STOP_LOSS_PCT) + logger.warning( + f"[DayTrading] {trade.stock_code} stop_loss 복원값 없음 → " + f"기본값 적용: {stop_loss:.0f}" + ) + if entry_price > 0 and target_price <= 0: + risk = entry_price - stop_loss + target_price = entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER + logger.warning( + f"[DayTrading] {trade.stock_code} target_price 복원값 없음 → " + f"기본값 적용: {target_price:.0f}" + ) + + _restored = { + "stock_name": trade.stock_name or trade.stock_code, + "strategy": trade.strategy_name or "day_trade", + "entry_price": entry_price, + "stop_loss": stop_loss, + "target_price": target_price, + "quantity": trade.quantity or 0, + "highest_price": float(trade.highest_since_entry) if trade.highest_since_entry else ( + float(trade.entry_price) if trade.entry_price else 0 + ), + "entry_time": trade.created_at, + "reason": trade.entry_reason or "", + "order_id": trade.order_id or "", + "restored_from_db": True, + } + # [M1 2026-10-02] 분할매도 이력 복원. exit_service 의 분할매도가 + # remaining_pct(<1.0) 를 ActiveTrade 에 남긴다. 복원 시 이를 보지 않으면 + # target_price 가 원래 목표가로 되살아나 재기동 직후 같은 50% 분할이 + # 재발동한다(잔량의 절반을 또 판다). 인메모리 분할 후 상태와 동일하게 + # 목표가를 제거(inf)하고 partial_sold 를 세운다 → 트레일링만 적용. + if _is_partial_sold_row(trade): + _restored["target_price"] = float("inf") + _restored["partial_sold"] = True + logger.warning( + f"[DayTrading] {trade.stock_code} 분할매도 이력 복원 " + f"(remaining_pct={trade.remaining_pct}) — 목표가 분할 재발동 차단, " + f"트레일링 고점={_restored['highest_price']:.0f}" + ) + return _restored + + +def _entry_date(entry_time) -> date | None: + """entry_time(datetime|None) → date. restore_from_db 의 created_at.date() 비교와 같은 기준.""" + if isinstance(entry_time, datetime): + return entry_time.date() + return None + + +async def _fetch_non_dt_active_qty_by_code_impl(codes: list, session_factory=None) -> dict[str, int]: + """비-DT(스윙·유입·legacy NULL) active ActiveTrade 종목별 수량 합 — SELECT 전용(§3.2 ③, commit 없음). + + close_stale_positions 와 같은 필터(db_worker.not_dt_strategy_clause). 실패는 예외로 전파한다. + """ + from app.models.models import ActiveTrade + from app.services.db_worker import not_dt_strategy_clause + + if session_factory is None: + from app.core.database import AsyncSessionLocal as session_factory # noqa: N813 + out: dict[str, int] = {} + if not codes: + return out + # DB-READ-EXCEPTION: 기동 시 DT 복원 검증(§3.5 타전략 차감), SELECT 전용·commit 없음 + async with session_factory() as db: + rows = (await db.execute( + select(ActiveTrade).where( + ActiveTrade.stock_code.in_(list(codes)), + ActiveTrade.status == "active", + not_dt_strategy_clause(ActiveTrade.strategy_name), + ) + )).scalars().all() + for r in rows or []: + out[r.stock_code] = out.get(r.stock_code, 0) + int(r.quantity or 0) + return out + def _is_partial_sold_row(trade) -> bool: """[M1] ActiveTrade 행이 분할매도 이후 상태인지 (remaining_pct < 1.0).""" @@ -560,6 +960,144 @@ def _is_partial_sold_row(trade) -> bool: return False +async def _fetch_today_trade_rows_impl(today: date, session_factory=None) -> list: + """당일(KST) TradeHistory 행 조회 — SELECT 전용(§3.2 ③, commit 없음).""" + from datetime import timedelta + from app.models.models import TradeHistory + + if session_factory is None: + from app.core.database import AsyncSessionLocal as session_factory # noqa: N813 + start = datetime.combine(today, datetime.min.time()) + end = start + timedelta(days=1) + # DB-READ-EXCEPTION: DT 일일 안전장치 원장 재구성(기동 시/미검증 재시도), SELECT 전용·commit 없음 + async with session_factory() as db: + result = await db.execute( + select(TradeHistory).where( + TradeHistory.created_at >= start, + TradeHistory.created_at < end, + TradeHistory.order_type.in_(["BUY", "SELL"]), + ).order_by(TradeHistory.created_at, TradeHistory.id) + ) + return list(result.scalars().all()) + + +def _rebuild_daily_state_from_rows(rows) -> dict: + """당일 TradeHistory 행 → 일일 안전장치 값 (순수 함수, 입력은 created_at·id 순 정렬 가정). + + 규칙 (exit_service._finalize_exit_impl 과 동일 기준): + - daily_pnl: DT SELL 의 net_profit_loss(비용 차감 순손익) 합. 취소·실패 행 제외. + **전일·고아 청산은 제외** — exit_reason 접두사, 또는 당일 DT BUY 가 없는 종목의 SELL. + - loss_streak_today: 종목별 **마지막** 당일 SELL 시점을 전량 청산으로 보고, 그 종목의 + 당일 SELL net 합(분할 레그 포함 = 왕복 순손익, exit_service._apply_round_trip_streak_v2)으로 + 시간순 판정한다. 손실 +1, 이익 0 리셋, 0 중립. 시간손절도 센다(§5.1 — loss_tracker 와 달리 당일 연패는 시간손절 포함). + - closed_today: DT SELL 종목 전부(취소 포함 — 전일 청산은 주문 실패해도 재진입 금지) + + 타 전략 체결 SELL 종목(전략 간 당일 쿨다운, swing/exit_service). 보수적 상위집합. + - dt_exited_today: 취소·실패가 아닌 DT SELL 종목(표시용). + - dt_reheld_today: [R2 하드닝] dt_exited_today 중 당일 DT 원장 순체결(BUY COMPLETED 체결 − + SELL 체결, exit_service._dt_net_qty_from_history 와 같은 규칙)이 양수인 종목 — 전량 매도가 + 부분체결로 종결돼 보유가 남은 경우. 기동 복원 검증이 '청산 완료'로 오판하지 않게 한다. + """ + dt_rows = [r for r in rows if str(getattr(r, "strategy_type", "") or "") == _DT_STRATEGY_TYPE] + buy_codes = { + str(r.stock_code) for r in dt_rows + if str(getattr(r, "order_type", "")).upper() == "BUY" + and str(getattr(r, "status", "") or "").upper() not in _VOID_STATUSES + } + closed: set[str] = set() + dt_exited: set[str] = set() + counted: list = [] + stale_excluded = 0 + dt_sell_rows = 0 + for r in rows: + if str(getattr(r, "order_type", "")).upper() != "SELL": + continue + code = str(r.stock_code) + status = str(getattr(r, "status", "") or "").upper() + is_dt = str(getattr(r, "strategy_type", "") or "") == _DT_STRATEGY_TYPE + if not is_dt: + if status not in _VOID_STATUSES: + closed.add(code) + continue + dt_sell_rows += 1 + closed.add(code) + if status in _VOID_STATUSES: + continue + dt_exited.add(code) + reason = str(getattr(r, "exit_reason", "") or "") + if reason.startswith(_STALE_EXIT_REASON_PREFIXES) or code not in buy_codes: + stale_excluded += 1 + continue + counted.append(r) + + pnl = 0.0 + last_idx: dict[str, int] = {} + for i, r in enumerate(counted): + net = getattr(r, "net_profit_loss", None) + if net is None: + logger.warning( + f"[DayTrading] 원장 재구성: {r.stock_code} SELL(id={getattr(r, 'id', '?')}) " + f"net_profit_loss 없음 — 0 으로 간주" + ) + pnl += float(net or 0) + last_idx[str(r.stock_code)] = i + # [R3 2026-10-09] 연패는 **왕복 순손익**(분할 레그 포함 종목별 당일 SELL net 합)으로 판정한다. + # 실시간 경로(exit_service._apply_round_trip_streak_v2, R2-4)와 같은 기준. 예전에는 마지막 + # 레그만 봐 +1.5R 분할 익절(+3,000) 뒤 잔량 -300 청산을 손실로 셌다 → Redis 미스 재기동만으로 + # 3연패 쿨다운이 걸릴 수 있었다. DT 는 당일 재진입이 금지(closed_today)라 종목당 왕복은 1회다. + streak = 0 + round_trip: dict[str, float] = {} + for i, r in enumerate(counted): + code = str(r.stock_code) + round_trip[code] = round_trip.get(code, 0.0) + float(getattr(r, "net_profit_loss", None) or 0) + if last_idx.get(code) != i: + continue # 분할 매도 — 왕복 합에만 누적 + net = round_trip[code] + if net < 0: + streak += 1 + elif net > 0: + streak = 0 + # [R2 하드닝] 당일 DT 순체결 수량 — 양수면 전량 매도가 일부만 체결돼 보유가 남았다. + net_qty: dict[str, int] = {} + for r in dt_rows: + code = str(r.stock_code) + ot = str(getattr(r, "order_type", "") or "").upper() + st = str(getattr(r, "status", "") or "").upper() + eq = getattr(r, "executed_quantity", None) + try: + q = int(eq if eq is not None else (getattr(r, "order_quantity", 0) or 0)) + except (TypeError, ValueError): + logger.warning(f"[DayTrading] 원장 재구성: {code} 수량 파싱 실패 — 순체결 판정 제외") + continue + if ot == "BUY" and st == "COMPLETED": + net_qty[code] = net_qty.get(code, 0) + q + elif ot == "SELL" and st not in ("FAILED", "REJECTED"): + net_qty[code] = net_qty.get(code, 0) - q + reheld = {c for c in dt_exited if net_qty.get(c, 0) > 0} + return { + "daily_pnl": pnl, + "loss_streak_today": streak, + "closed_today": closed, + "dt_exited_today": dt_exited, + "dt_reheld_today": reheld, + "stale_excluded": stale_excluded, + "dt_sell_rows": dt_sell_rows, + } + + +async def _notify_daily_state_impl(message: str) -> None: + """텔레그램 시스템 경고 (실패해도 예외를 던지지 않는다).""" + try: + from app.services.telegram_notifier import telegram_notifier + await asyncio.wait_for( + telegram_notifier.notify_system_warning("DAYTRADING", message), + timeout=_DAILY_STATE_NOTIFY_TIMEOUT_SEC, + ) + except asyncio.TimeoutError: + logger.warning("[DayTrading] 일일 안전장치 텔레그램 알림 시간 초과") + except Exception as e: + logger.warning(f"[DayTrading] 일일 안전장치 텔레그램 알림 실패: {e}") + + # 싱글톤 인스턴스 — daytrading 패키지 전체에서 공유 # entry_service, exit_service, scheduler에서 동일 인스턴스 참조 day_trading_state = DayTradingState() diff --git a/backend/app/services/daytrading/scheduler.py b/backend/app/services/daytrading/scheduler.py index 4ca182d..de26933 100644 --- a/backend/app/services/daytrading/scheduler.py +++ b/backend/app/services/daytrading/scheduler.py @@ -5,12 +5,20 @@ main.py의 lifespan에서 asyncio.create_task()로 시작되는 30초 주기 실행 흐름: - 장외 시간: 30분 슬립 (CPU 낭비 방지) - - DAY_TRADE_ENABLED=False: 60초 슬립 + - DAY_TRADE_ENABLED=False: **신규 진입만** 중단(ORB 수집·watchlist·진입 스캔 생략). + EOD·전일/고아·손절·시간손절 청산은 계속 수행한다(§8.1-11 청산 우선, 2026-10-09). + 인메모리 보유가 없으면 전일·고아 점검은 DISABLED_ORPHAN_CHECK_INTERVAL_SEC 주기로 줄인다. - 09:00~09:30: ORB 수집 (collect_opening_ranges) - 09:30~14:30: 신호 스캔 + 진입 (scan_and_enter) + 청산 체크 (check_and_exit_positions) - 14:30~15:20: 청산 체크만 (신규 진입 마감) - 15:20~15:30: EOD 강제 청산 (force_close_all) +진입 스캔·ORB 수집 중 손절 감시 (2026-10-09): + - 진입 스캔(최대 150초)·ORB 수집/소급수집(최대 300초)은 순차 실행이라 그동안 보유 + 포지션의 손절 판정이 멈췄다. 이 단계들을 _run_with_stop_watch_impl 로 감싸 + SCAN_STOP_WATCH_INTERVAL_SEC 마다 손절 선판정(_fast_stop_check_impl, 현재가 1회)을 + 병행한다. 스캔 중 청산으로 일일 손실한도·3연패 쿨다운에 걸리면 스캔을 중단한다. + MDD 규칙 (CLAUDE.md 5.3): - MDD -10% 이하 시 신규 진입 차단 - 기존 포지션의 청산 체크는 MDD 무관하게 계속 수행 (포지션 보호) @@ -38,8 +46,11 @@ from app.services.daytrading.entry_service import ( from app.services.daytrading.exit_service import ( check_and_exit_positions, force_close_all, check_stops_only_v2, ) +# close_stale_positions 는 호출 시점에 모듈 속성으로 참조한다(테스트 패치가 새지 않게). +from app.services.daytrading import exit_service as _dt_exit from app.core.log_throttle import warn_throttled # noqa: E402 +from app.services.day_trading_strategy import is_daily_loss_exceeded # noqa: E402 logger = logging.getLogger(__name__) @@ -60,6 +71,201 @@ async def _guard(coro, step: str, timeout: int = STEP_TIMEOUT): return None +# [2026-10-09] 진입 스캔·ORB 수집 중 손절 감시 주기(초). 설정에 같은 이름의 키가 있으면 +# 그 값을 쓴다(현재 config.py 에는 없다 — 기본값 15초). 최소 5초. +SCAN_STOP_WATCH_INTERVAL_SEC: float = 15.0 +_SCAN_STOP_WATCH_MIN_SEC: float = 5.0 + +# [2026-10-09] DAY_TRADE_ENABLED=False 이고 인메모리 보유가 0일 때 전일·고아 DT 잔고 +# 점검(close_stale_positions: 잔고 조회 + SELECT) 주기. 보유가 있으면 매 사이클 판정한다. +DISABLED_ORPHAN_CHECK_INTERVAL_SEC: float = 300.0 +_last_disabled_orphan_check: float = 0.0 + + +# [하드닝 R2] DT 청산 단계 연속 실패(예외·_guard 타임아웃 None) 경보. 스윙의 +# _record_exit_failure_v2 에 대응. 실패가 이어지면 손절·시간손절·트레일링이 멈춘 것이다. +DT_EXIT_FAILURE_ALERT_THRESHOLD: int = 3 +_dt_exit_health: dict = {"consecutive_failures": 0, "last_error": None} + + +# [하드닝 R3] fire-and-forget 경보 태스크 강참조 보관(GC 방지). 이벤트 루프는 태스크를 +# 약참조로만 들고 있어, 참조를 버리면 텔레그램 HTTP await 중 GC 로 경보가 사라질 수 있다. +_alert_tasks: set = set() +DT_ALERT_SEND_TIMEOUT_SEC: float = 10.0 + + +async def _send_dt_warning_impl(message: str) -> None: + """경보 1건 발송 — 타임아웃 상한 + 실패는 error 로그(삼키지 않고 남긴다).""" + try: + from app.services.telegram_notifier import telegram_notifier + await asyncio.wait_for( + telegram_notifier.notify_system_warning(category="DAYTRADING", message=message), + timeout=DT_ALERT_SEND_TIMEOUT_SEC, + ) + except asyncio.TimeoutError: + logger.error( + f"[DayTrading] 경보 텔레그램 {DT_ALERT_SEND_TIMEOUT_SEC}초 초과 — 미발송: {message}" + ) + except Exception as e: + logger.error(f"[DayTrading] 경보 텔레그램 실패: {type(e).__name__}: {e} — 미발송: {message}") + + +def _notify_dt_warning_bg_v2(message: str) -> None: + try: + _t = asyncio.get_running_loop().create_task(_send_dt_warning_impl(message)) + _alert_tasks.add(_t) + _t.add_done_callback(_alert_tasks.discard) + except Exception as e: + logger.error(f"[DayTrading] 경보 태스크 생성 실패: {type(e).__name__}: {e} — 미발송: {message}") + + +def _record_dt_exit_result_v2(ok: bool, err: str = "") -> None: + """청산 단계 결과 기록. 연속 실패가 임계값에 도달하면 경보(이후 임계값 배수마다 재경보).""" + if ok: + _dt_exit_health["consecutive_failures"] = 0 + _dt_exit_health["last_error"] = None + return + _dt_exit_health["consecutive_failures"] += 1 + _dt_exit_health["last_error"] = err + n = _dt_exit_health["consecutive_failures"] + logger.warning(f"[DayTrading] 청산 단계 실패 {n}회 연속: {err}") + if n % DT_EXIT_FAILURE_ALERT_THRESHOLD == 0: + logger.error(f"[DayTrading] 🚨 청산 단계 {n}회 연속 실패 — 손절·시간손절 멈춤 가능: {err}") + _notify_dt_warning_bg_v2(f"DT 청산 {n}회 연속 실패(손절 정지 가능): {err}") + + +def _beat_v2(summary: str) -> None: + try: + from app.core import heartbeat as _hb + _hb.beat("daytrading", summary) + except Exception as e: + logger.warning(f"[DayTrading] heartbeat 기록 실패: {type(e).__name__}: {e}") + + +async def _cycle_sleep(seconds: float) -> None: + """루프 대기 — 테스트가 사이클 경계를 잡을 수 있게 한 곳으로 모은다.""" + await asyncio.sleep(seconds) + + +def _stop_watch_interval() -> float: + raw = getattr(settings, "DAY_TRADE_SCAN_STOP_WATCH_INTERVAL_SEC", None) + try: + val = float(raw) if raw is not None else SCAN_STOP_WATCH_INTERVAL_SEC + except (TypeError, ValueError): + val = SCAN_STOP_WATCH_INTERVAL_SEC + if val <= 0: + val = SCAN_STOP_WATCH_INTERVAL_SEC + return max(_SCAN_STOP_WATCH_MIN_SEC, val) + + +async def _stop_watch_round_impl(lock_held: bool) -> list[dict]: + """보유 DT 포지션 손절선 선판정 1회. 청산 결과 목록. + + lock_held=True: 호출자(스케줄러)가 이미 dt_cycle_lock 을 쥔 단계(진입 스캔) 안에서 돈다. + lock_held=False: 락 밖 단계(ORB 수집·소급수집) — 라운드마다 락을 잡고, 다른 사이클 + (/cycle 수동 실행)이 쥐고 있으면 그쪽이 청산 체크를 하므로 이번 라운드는 건너뛴다. + 전일 포지션은 count_daily_pnl=False 경로(close_stale_positions)가 맡으므로 제외한다. + 중복 매도는 _execute_exit_impl 의 exits_in_progress·포지션 동일성 검사가 막는다. + """ + codes = [ + c for c, p in list(day_trading_state.positions.items()) + if not _dt_exit._is_stale_position(p) + ] + if not codes: + return [] + timeout = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + 5.0 + + async def _run() -> list[dict]: + res = await _dt_exit._run_exit_checks_bounded_impl( + codes, _dt_exit._fast_stop_check_impl, timeout, "스캔 중 손절 감시", + ) + return [r[1] for r in res.values() if r and r[1]] + + if lock_held: + return await _run() + if dt_cycle_lock.locked(): + return [] + async with dt_cycle_lock: + return await _run() + + +def _entry_blocked_by_exit_results() -> str | None: + """스캔 도중 청산으로 신규 진입 차단 조건이 새로 걸렸는지 — scan_and_enter 의 선행 + 게이트(일일 손실한도·3연패 쿨다운)와 같은 판정. 걸렸으면 사유 문자열.""" + if is_daily_loss_exceeded(day_trading_state.last_total_assets, day_trading_state.daily_pnl): + return "일일 손실 한도 초과" + if day_trading_state.is_cooldown_active(): + return "당일 연패 쿨다운" + return None + + +async def _run_with_stop_watch_impl( + coro, step: str, timeout: float, *, lock_held: bool, abort_on_entry_block: bool = False, +): + """오래 걸리는 단계(coro)를 _guard 로 실행하면서, 그동안 주기적으로 손절 선판정을 병행한다. + + abort_on_entry_block=True(진입 스캔)면 감시 청산 뒤 일일 손실한도·연패 쿨다운이 걸릴 때 + 단계를 취소한다(남은 종목 진입 차단). 매수 주문~상태 반영은 entry_service 가 shield 하므로 + 취소돼도 이미 나간 주문의 기록은 끝까지 진행된다(_guard 타임아웃 취소와 같은 성격). + """ + stop = asyncio.Event() + interval = _stop_watch_interval() + main_task = asyncio.ensure_future(_guard(coro, step, timeout)) + aborted = {"reason": None} + + async def _watch() -> None: + while not stop.is_set(): + try: + await asyncio.wait_for(stop.wait(), timeout=interval) + return + except asyncio.TimeoutError: + pass + if not day_trading_state.positions: + continue + try: + exits = await _stop_watch_round_impl(lock_held) + except Exception as e: + logger.error(f"[DayTrading] '{step}' 중 손절 감시 오류: {e}", exc_info=True) + continue + if not exits: + continue + logger.warning(f"[DayTrading] '{step}' 중 손절 감시 청산: {len(exits)}건") + if abort_on_entry_block and not main_task.done(): + _why = _entry_blocked_by_exit_results() + if _why: + aborted["reason"] = _why + logger.warning( + f"[DayTrading] ⛔ '{step}' 중 청산으로 {_why} — 남은 진입 스캔 중단" + ) + main_task.cancel() + + watcher = asyncio.ensure_future(_watch()) + try: + try: + return await main_task + except asyncio.CancelledError: + # 감시가 스스로 취소한 경우만 흡수한다. 바깥(스케줄러 종료) 취소는 전파. + _cur = asyncio.current_task() + _outer = bool(_cur and getattr(_cur, "cancelling", lambda: 0)()) + if aborted["reason"] and main_task.cancelled() and not _outer: + return None + raise + finally: + stop.set() + _cap = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + 10.0 + try: + await asyncio.wait_for(watcher, timeout=_cap) + except asyncio.TimeoutError: + logger.warning( + f"[DayTrading] '{step}' 손절 감시 종료 {_cap:.0f}초 초과 — 취소 " + f"(전송된 매도는 shield 로 반영 계속)" + ) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"[DayTrading] '{step}' 손절 감시 종료 오류: {e}", exc_info=True) + + # [M1 2026-09-28] 장중 기동 시 ORB 소급수집 지연 실행 플래그. # 기동 시퀀스에서 바로 await 하면 첫 청산 체크가 수 분 밀린다 → 루프 안에서 # 청산 체크 뒤에 1회만 실행한다(§8.1-11 청산 우선). @@ -83,8 +289,8 @@ async def daytrading_scheduler() -> None: - 엔트리 윈도우: 진입 + 청산 동시 처리 - 기타: 보유 포지션 청산 체크만 """ - global _orb_backfill_pending - await asyncio.sleep(5) + global _orb_backfill_pending, _last_disabled_orphan_check + await _cycle_sleep(5) logger.info("⚡ [DayTrading] Scheduler started (every 30s)") # ── 재기동 복원 시퀀스 (순서가 안전에 직결) ──────────────────────────── @@ -104,10 +310,15 @@ async def daytrading_scheduler() -> None: except Exception as e: logger.warning(f"[DayTrading] 일일 안전장치 복원 실패: {e}") if not daily_restored: - logger.warning( - "[DayTrading] ⚠️ 일일 안전장치 복원분 없음 — 당일 실현손익 0 / 연패 0 / " - "당일청산 0종목 으로 시작한다(일일 손실한도·연패 쿨다운·재진입 차단 초기화). " - "당일 첫 기동이면 정상이다." + # [R2 2026-10-09] Redis 단일 소스였다 — 복원 실패(Redis 재기동·volatile-lru 축출·저장 실패) + # 면 0 부터 매매를 재개했다(fail-open). 당일 DT 원장(TradeHistory, SELECT 전용)으로 재구성하고, + # 그것도 실패하면 position_manager 가 DT 신규 진입을 막고(is_cooldown_active) 텔레그램으로 + # 알린 뒤 재시도한다. 이 메서드는 예외를 던지지 않는다. + daily_restored = await day_trading_state.rebuild_daily_state_after_redis_miss_v2() + if not daily_restored: + logger.error( + "[DayTrading] 🚨 일일 안전장치 복원 불가(Redis·TradeHistory) — DT 신규 진입 차단, " + f"청산은 계속 (사유: {day_trading_state.daily_state_unverified_reason})" ) # 재기동 시 당일 ORB 캐시 복원 — 없으면 그날 ORB 전략이 통째로 불가능해진다 @@ -128,18 +339,33 @@ async def daytrading_scheduler() -> None: restored = await day_trading_state.restore_from_db(db) or 0 if restored: logger.info(f"[DayTrading] 시작 시 {restored}개 포지션 복원됨") - # 전일 미청산 포지션은 기동 직후(장중이면) 즉시 청산 — EOD 누락 안전장치 - # (전일 진입분은 복원된 채로 두어 close_stale_positions 가 잡는다 — §7) - try: - from app.services.daytrading.exit_service import close_stale_positions - stale = await close_stale_positions() - if stale: - logger.warning(f"[DayTrading] 기동 시 전일 포지션 {len(stale)}건 청산") - except Exception as e: - logger.error(f"[DayTrading] 기동 시 전일 포지션 청산 실패: {e}", exc_info=True) except Exception as e: logger.error(f"[DayTrading] 시작 시 포지션 복원 실패: {e}", exc_info=True) + # [R3 2026-10-09] 당일 복원분 검증 — closed_today·KIS 실보유−타전략(§3.5) 기준. + # restore_from_db 는 이를 보지 않아 청산 큐 유실 뒤 재기동하면 이미 판 물량을 다시 팔았다. + # 불일치는 복원 보류 + 경보(장중 close_stale_positions 가 같은 규칙으로 재복원). 예외를 던지지 않는다. + if restored: + try: + _vr = await day_trading_state.verify_restored_today_positions_v2() + if _vr.get("held"): + restored = max(0, restored - len(_vr["held"])) + except Exception as e: + logger.error(f"[DayTrading] 기동 복원 검증 실패: {e}", exc_info=True) + + # 전일 미청산 포지션은 기동 직후(장중이면) 즉시 청산 — EOD 누락 안전장치 + # (전일 진입분은 복원된 채로 두어 close_stale_positions 가 잡는다 — §7) + # [2026-10-08] 복원 0건이어도 호출한다 — active 행이 이미 closed 인 DT 고아 잔고 + # (Portfolio 'daytrading' 만 남은 경우, 10/06 KB금융)는 복원 대상이 아니라서 + # 예전에는 기동 시 점검 자체가 생략됐다. 장외면 함수가 즉시 [] 를 반환한다. + try: + async with dt_cycle_lock: + stale = await _guard(_dt_exit.close_stale_positions(), "기동 시 전일·고아 포지션 청산") + if stale: + logger.warning(f"[DayTrading] 기동 시 전일·고아 포지션 {len(stale)}건 청산") + except Exception as e: + logger.error(f"[DayTrading] 기동 시 전일 포지션 청산 실패: {e}", exc_info=True) + # [F5] 복원 성공 로그만으로는 "살아남았는지"를 알 수 없었다 → 확정 개수를 찍는다. _alive_pos = day_trading_state.position_count() _alive_orb = len(day_trading_state.orb_cache) @@ -178,6 +404,7 @@ async def daytrading_scheduler() -> None: try: while True: try: + _beat_v2("loop") day_trading_state.reset_if_new_day() if not is_market_open(): @@ -185,7 +412,8 @@ async def daytrading_scheduler() -> None: # ORB 수집(09:00~09:30)을 통째로 놓칠 수 있다 → 개장까지 남은 시간만큼만 대기. _until_open = seconds_until_market_open() # 개장 3분 전이면 watchlist를 미리 채워 09:00부터 ORB 수집 가능 - if _until_open is not None and _until_open <= 180 and not get_watchlist(): + if (settings.DAY_TRADE_ENABLED and _until_open is not None + and _until_open <= 180 and not get_watchlist()): try: count = await _guard(populate_daytrading_watchlist(), "watchlist 구성", 120) if count: @@ -193,19 +421,39 @@ async def daytrading_scheduler() -> None: except Exception as e: logger.warning(f"[DayTrading] 개장 전 watchlist 구성 실패: {e}") _sleep = 1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open)) - await asyncio.sleep(_sleep) + await _cycle_sleep(_sleep) continue - if not settings.DAY_TRADE_ENABLED: - await asyncio.sleep(60) - continue + # [2026-10-09] DAY_TRADE_ENABLED=False 는 **신규 진입만** 막는다. + # 예전에는 여기서 sleep(60)+continue 로 EOD·전일/고아·손절 청산까지 전부 + # 건너뛰어, 장애 대응으로 끈 채 재기동하면 DT 보유분이 손절 없이 오버나이트로 + # 넘어갔다(DT 잔고는 Portfolio 'daytrading' 태그라 스윙 청산에서도 빠진다). + dt_enabled = bool(settings.DAY_TRADE_ENABLED) + if not dt_enabled: + warn_throttled( + logger, "dt_sched:disabled", + "[DayTrading] DAY_TRADE_ENABLED=False — 신규 진입 중단, 보유·전일·고아 " + "포지션 청산 판정만 수행 (보유 %d개)", + day_trading_state.position_count(), interval=600.0, + ) + if not day_trading_state.positions: + _now_mono = time.monotonic() + if (_last_disabled_orphan_check + and _now_mono - _last_disabled_orphan_check + < DISABLED_ORPHAN_CHECK_INTERVAL_SEC): + await _cycle_sleep(30) + continue + _last_disabled_orphan_check = _now_mono - # MDD 체크 (CLAUDE.md 5.3) - short_term_ok = await _guard( - mdd_manager.is_short_term_strategy_enabled(), "MDD 체크", 30 - ) - if short_term_ok is None: - short_term_ok = False # 판단 불가 → 보수적으로 신규 진입 차단 + # MDD 체크 (CLAUDE.md 5.3) — 진입 판단 전용. 비활성이면 조회하지 않는다. + if dt_enabled: + short_term_ok = await _guard( + mdd_manager.is_short_term_strategy_enabled(), "MDD 체크", 30 + ) + if short_term_ok is None: + short_term_ok = False # 판단 불가 → 보수적으로 신규 진입 차단 + else: + short_term_ok = False # EOD 강제 청산 (15:20) if is_eod_flat_time(): @@ -216,7 +464,15 @@ async def daytrading_scheduler() -> None: ) if exits: logger.info(f"[DayTrading] EOD 청산: {len(exits)}건") - await asyncio.sleep(30) + else: + # [2026-10-08] 인메모리가 비어도 전일·고아 DT 잔고는 남아 있을 수 있다 + async with dt_cycle_lock: + orphan_exits = await _guard( + _dt_exit.close_stale_positions(), "EOD 전일·고아 포지션 청산", + ) + if orphan_exits: + logger.warning(f"[DayTrading] EOD 전일·고아 청산: {len(orphan_exits)}건") + await _cycle_sleep(30) continue # 09:00~09:30 ORB 수집 @@ -229,17 +485,21 @@ async def daytrading_scheduler() -> None: orb_exits = await _guard(check_stops_only_v2(), "ORB 구간 손절 체크") if orb_exits: logger.info(f"[DayTrading] ORB 구간 청산: {len(orb_exits)}건") - if not get_watchlist(): - try: - count = await populate_daytrading_watchlist() - if count: - logger.info(f"[DayTrading] 자동 watchlist: {count}개") - except Exception as e: - logger.warning(f"[DayTrading] watchlist 자동설정 실패: {e}") - await _guard(collect_opening_ranges(), "ORB 수집", 300) + if dt_enabled: + if not get_watchlist(): + try: + count = await populate_daytrading_watchlist() + if count: + logger.info(f"[DayTrading] 자동 watchlist: {count}개") + except Exception as e: + logger.warning(f"[DayTrading] watchlist 자동설정 실패: {e}") + # [2026-10-09] ORB 수집(최대 300초) 중에도 손절 감시 병행 + await _run_with_stop_watch_impl( + collect_opening_ranges(), "ORB 수집", 300, lock_held=False, + ) # 정규 수집 구간이면 소급수집 예약은 불필요 _orb_backfill_pending = False - await asyncio.sleep(30) + await _cycle_sleep(30) continue # 09:30~15:00 신호 스캔 + 청산 체크 @@ -248,7 +508,9 @@ async def daytrading_scheduler() -> None: wl = get_watchlist() # 30분마다 갱신 (비어 있을 때만 → 항상): 09:00에 전일 분석으로 채운 목록을 # 당일 배치 완료 후 당일 분석 결과로 교체하기 위함 (ORB 캐시는 종목코드 기준 유지) - if not wl or (is_watchlist_auto() and (time.time() - _last_wl_sync) > 1800): + if dt_enabled and ( + not wl or (is_watchlist_auto() and (time.time() - _last_wl_sync) > 1800) + ): _last_wl_sync = time.time() try: await populate_daytrading_watchlist() @@ -265,45 +527,65 @@ async def daytrading_scheduler() -> None: # [M5] 수동 /cycle API 와 공유하는 전역 락 — 같은 포지션 이중 매도 방지 async with dt_cycle_lock: exits = await _guard(check_and_exit_positions(), "청산 체크") + _record_dt_exit_result_v2(exits is not None, "청산 체크 타임아웃") + if exits is not None: + _beat_v2("exit_ok") if exits: logger.info(f"[DayTrading] 청산: {len(exits)}건") # [M1] 장중 기동 ORB 소급수집 — 청산 체크를 먼저 끝낸 뒤 1회만 - if _orb_backfill_pending: + if _orb_backfill_pending and dt_enabled: _orb_backfill_pending = False try: from app.services.daytrading.entry_service import ( backfill_opening_ranges, ) - await _guard(backfill_opening_ranges(), "ORB 소급수집", 300) + # [2026-10-09] 소급수집(최대 300초) 중에도 손절 감시 병행 + await _run_with_stop_watch_impl( + backfill_opening_ranges(), "ORB 소급수집", 300, lock_held=False, + ) except Exception as e: logger.error( f"[DayTrading] 장중 기동 ORB 소급수집 실패: {e}", exc_info=True ) - # 진입 (MDD 허용 시만) - if short_term_ok: + # 진입 (DAY_TRADE_ENABLED + MDD 허용 시만) + if not dt_enabled: + pass # 진입 비활성 — 청산만(위에서 수행) + elif short_term_ok: + # [2026-10-09] 스캔(최대 150초, _guard 180초) 동안 손절 판정이 멈추던 + # 문제 — 스캔과 병행해 손절 선판정을 주기 실행한다. 락은 이 단계가 + # 이미 쥐고 있으므로 감시는 같은 락 아래에서 돈다(/cycle 과 배타 유지). async with dt_cycle_lock: - entries = await _guard(scan_and_enter(), "진입 스캔") + entries = await _run_with_stop_watch_impl( + scan_and_enter(), "진입 스캔", STEP_TIMEOUT, + lock_held=True, abort_on_entry_block=True, + ) if entries: logger.info(f"[DayTrading] 진입: {len(entries)}건") else: logger.info("[DayTrading] ⏸️ MDD -10% 이상 — 신규 진입 차단") - await asyncio.sleep(30) + await _cycle_sleep(30) continue # 그 외 시간 — 보유 포지션 청산 체크만 - if day_trading_state.positions: - async with dt_cycle_lock: - exits = await _guard(check_and_exit_positions(), "장외 청산 체크") - if exits: - logger.info(f"[DayTrading] 장외 청산: {len(exits)}건") + # [2026-10-08] 인메모리가 비어 있어도 호출한다 — check_and_exit_positions 는 + # 그 경우 close_stale_positions(전일·고아 DT 잔고 청산)만 수행한다(15:00 이후도 + # 청산은 허용, §8.1-11). + async with dt_cycle_lock: + exits = await _guard(check_and_exit_positions(), "장외 청산 체크") + _record_dt_exit_result_v2(exits is not None, "장외 청산 체크 타임아웃") + if exits is not None: + _beat_v2("exit_ok") + if exits: + logger.info(f"[DayTrading] 장외 청산: {len(exits)}건") except Exception as e: logger.error(f"[DayTrading] 스케줄러 오류: {e}", exc_info=True) + _record_dt_exit_result_v2(False, f"{type(e).__name__}: {e}") - await asyncio.sleep(30) + await _cycle_sleep(30) except asyncio.CancelledError: logger.info("[DayTrading] Scheduler stopped") diff --git a/backend/app/services/db_worker.py b/backend/app/services/db_worker.py index d8eeee0..b38478b 100644 --- a/backend/app/services/db_worker.py +++ b/backend/app/services/db_worker.py @@ -80,12 +80,20 @@ _ACTIVE_TRADE_META_KEYS = ( # [M-A 2026-10-02] 멱등화 마커 — 워커가 처리 중에 채운다(ORM 컬럼 아님, 호출부는 넣지 않는다). "_target_id", "_applied_remaining_pct", + # [2026-10-08] 스윙 진입가 정합 — 추가매수 가중평균 / 체결가 보정 (ORM 컬럼 아님) + "accumulate_entry", + "entry_fill_adjust", + "_applied_entry_price", + "_applied_quantity", + "_retried_exec", # [2026-10-09] 재시도 실행 표식(경고용, ORM 컬럼 아님) ) # 정상 경로 재시도·DLQ 저장 전에 지우는 마커. `_applied_remaining_pct` 는 "이번 실행이 # 계산한 절대값"이라, 실패 후 재시도(큐 뒤로 재등록 → 그 사이 다른 multiply 가 먼저 적용될 수 # 있음)에서 재사용하면 그 갱신을 덮어쓴다. `_target_id`(close 대상 고정)는 유지한다 — # 재시도도 처음 고른 행을 닫는 것이 맞다. -_RETRY_STRIP_MARKERS: tuple[str, ...] = ("_applied_remaining_pct",) +_RETRY_STRIP_MARKERS: tuple[str, ...] = ( + "_applied_remaining_pct", "_applied_entry_price", "_applied_quantity", +) _PINNED_TASK_TYPES: tuple[str, ...] = ("close_active_trade", "upsert_active_trade") @@ -728,6 +736,82 @@ async def replay_dlq() -> int: return requeued +# [2026-10-09] 런타임 DLQ 적재/워커 적체 경보. 같은 키는 이 간격(초)에 1회만 텔레그램 발송. +DLQ_ALERT_INTERVAL_SEC = 300.0 +WORKER_QUEUE_ALERT_DEPTH = 200 # qsize 가 이 값 이상이면 경보 +DLQ_RUNTIME_REPLAY_INTERVAL_SEC = 300.0 # 런타임 pending DLQ 재처리 주기 +CIRCUIT_OPEN_FAILURES = 5 # 연속 실패 이 횟수 이상이면 DB 장애로 간주(회로 열림) +CIRCUIT_RETRY_DELAY_SEC = 8.0 +CIRCUIT_MAX_HOLD_SEC = 120.0 # 회로 열림 상태로 재시도 횟수를 아끼는 최대 시간 +_alert_tasks: set = set() +# 워커 상태 스냅샷 (/health·경보용). 워커가 갱신한다. +_worker_state: dict = {"consecutive_failures": 0, "circuit_open": False, "last_success_ts": None} + + +async def _send_alert_impl(category: str, message: str) -> None: + try: + from app.services.telegram_notifier import telegram_notifier + await asyncio.wait_for(telegram_notifier.notify_system_warning(category, message), timeout=10) + except Exception as e: + logger.warning("[DBWorker] 텔레그램 경보 전송 실패(%s): %s", category, e) + + +def _alert_throttled_v2(key: str, category: str, message: str, + interval: float = DLQ_ALERT_INTERVAL_SEC) -> bool: + """throttle 된 비차단 텔레그램 경보. 이벤트 루프가 없으면 로그만 남긴다.""" + from app.core.log_throttle import should_log + ok, suppressed = should_log(key, interval) + if not ok: + return False + if suppressed: + message = f"{message} (+{suppressed}건 억제)" + try: + fut = asyncio.ensure_future(_send_alert_impl(category, message)) + except RuntimeError as e: + logger.warning("[DBWorker] 경보 태스크 생성 실패: %s", e) + return False + _alert_tasks.add(fut) + fut.add_done_callback(_alert_tasks.discard) + return True + + +QUEUE_DEPTH_CHECK_INTERVAL_SEC = 30.0 # 성공 경로 적체 점검 주기 +SLOW_TASK_ALERT_SEC = 5.0 # 단일 쓰기가 이 시간 이상 걸리면 느린 DB 로 간주 +_depth_check_state: dict = {"last": 0.0} + + +def _check_queue_backlog_v2(queue: asyncio.Queue, task_elapsed: float | None = None, + now: float | None = None) -> bool: + """[2026-10-09] 성공 경로 적체 점검. 쓰기가 실패 없이 느린 경우에도 qsize 경보를 낸다. + + QUEUE_DEPTH_CHECK_INTERVAL_SEC 마다 1회만 평가(핫패스 비용 최소). 경보는 실패 경로와 + 같은 키로 throttle 되어 중복 발송되지 않는다. + """ + t = time.monotonic() if now is None else now + if t - _depth_check_state["last"] < QUEUE_DEPTH_CHECK_INTERVAL_SEC: + return False + _depth_check_state["last"] = t + depth = queue.qsize() + if depth < WORKER_QUEUE_ALERT_DEPTH: + return False + slow = f", 직전 쓰기 {task_elapsed:.1f}s" if task_elapsed is not None else "" + return _alert_throttled_v2( + "db_worker:queue_depth", "DB_WORKER", + f"DB 쓰기 큐 적체 qsize={depth} (실패 없음{slow}). DB 지연 확인 필요.", + ) + + +def get_worker_health_v2() -> dict: + """큐 적체·회로 상태 (동기, DB 접근 없음). /health 노출용.""" + q = _db_task_queue + return { + "qsize": q.qsize() if q is not None else 0, + "consecutive_failures": _worker_state["consecutive_failures"], + "circuit_open": _worker_state["circuit_open"], + "last_success_ts": _worker_state["last_success_ts"], + } + + async def _save_to_dlq(task: dict, error: str) -> None: """3회 재시도 실패한 task를 DLQ 파일로 저장. @@ -760,11 +844,20 @@ async def _save_to_dlq(task: dict, error: str) -> None: "[DLQ] Task saved to DLQ: %s | type=%s | error=%s", filename, task_type, error, ) + _alert_throttled_v2( + "db_worker:dlq_saved", "DLQ", + f"DB 쓰기 {task_type} 가 재시도 실패로 DLQ 에 적재됨 " + f"(ActiveTrade·밴 등 DB 상태가 어긋날 수 있음). 주기 재처리가 복구를 시도한다.", + ) except Exception as e: logger.critical( "[DLQ] Failed to save to DLQ: %s | task_type=%s", e, task.get("type", "unknown"), ) + _alert_throttled_v2( + "db_worker:dlq_save_failed", "DLQ", + f"DLQ 파일 저장 자체가 실패 — 태스크 유실 (type={task.get('type', 'unknown')}): {e}", + ) # ───────────────────────────────────────────────────────────────────────────── @@ -1233,6 +1326,117 @@ async def _handle_update_trade_history_by_order_id(session, data: dict) -> None: ) +def _apply_entry_price_adjustments_v2(record, data: dict) -> frozenset: + """[2026-10-08] 기존 active 행의 진입가·수량을 '누적 원가' 기준으로 갱신한다. + + - ``accumulate_entry=True`` (추가매수): quantity = 기존 + 신규, entry_price = 수량 가중평균, + highest_since_entry = max(기존, 신규). 이전에는 신규 매수 수량·주문가로 **덮어써** + 스윙 진입가가 마지막 추가매수가로 바뀌었다(스윙 손익·손절선 왜곡). + - ``entry_fill_adjust={"order_price","filled_price","filled_qty"}`` (체결 확인 후): + entry_price += (체결가 − 주문가) × 체결수량 / 행 수량. 신규 진입(행 수량 == 체결수량)이면 + 정확히 체결가가 된다. 추가매수였다면 그 매수분의 원가 차이만 반영된다. + + 둘 다 비멱등이라 계산한 절대값을 payload 에 고정한다(`_applied_*` + `_target_id`, + remaining_pct_multiply 와 같은 규약). 정상 재시도에서는 `_strip_retry_markers` 가 지운다. + + Returns: + 일반 setattr 루프에서 건너뛸 키 집합. + """ + skip: set = set() + _rid = getattr(record, "id", None) + _pinned = ( + data.get("_applied_entry_price") is not None + and _rid is not None + and data.get("_target_id") == _rid + ) + + if data.get("accumulate_entry"): + # [2026-10-09] remaining_pct 도 함께 재계산한다(불변식: 실보유 = quantity × remaining_pct). + # 호출부가 넘긴 remaining_pct 는 무시한다(옛 스냅샷 값이 재계산을 덮어쓰는 것을 방지). + skip.update({"quantity", "entry_price", "highest_since_entry", "remaining_pct"}) + if _pinned: + record.entry_price = float(data["_applied_entry_price"]) + if data.get("_applied_quantity") is not None: + record.quantity = int(data["_applied_quantity"]) + if data.get("_applied_remaining_pct") is not None: + record.remaining_pct = float(data["_applied_remaining_pct"]) + else: + old_q = int(getattr(record, "quantity", 0) or 0) + old_p = float(getattr(record, "entry_price", 0) or 0) + add_q = int(data.get("quantity") or 0) + add_p = float(data.get("entry_price") or 0) + new_q = old_q + add_q + # 가중치는 '현재 보유분' = 원본 수량 × remaining_pct (분할 익절 후 추가매수 시 + # 이미 판 물량의 원가가 평단에 섞이지 않게). quantity 컬럼은 원본 누적 규약 유지. + try: + _rp = float(getattr(record, "remaining_pct", 1.0) or 1.0) + except (TypeError, ValueError): + _rp = 1.0 + _rp = min(1.0, max(0.0, _rp)) + held_q = old_q * _rp + if add_q > 0 and add_p > 0 and held_q > 0 and old_p > 0: + new_p = (held_q * old_p + add_q * add_p) / (held_q + add_q) + elif add_q > 0 and add_p > 0: + new_p = add_p + else: + new_p = old_p + record.quantity = new_q + record.entry_price = round(float(new_p), 2) # Numeric(15,2) + # 분할 익절 뒤 추가매수: 원본 누적(quantity)이 늘었으므로 remaining_pct 를 + # (현재 보유 + 추가) / (원본 + 추가) 로 맞춘다. 안 맞추면 실보유/remaining_pct 로 + # 역산한 원본이 부풀어 다음 분할매도 수량이 과대(과다매도)가 된다. + _rp_changed = add_q > 0 and new_q > 0 and _rp < 1.0 + if _rp_changed: + record.remaining_pct = round(min(1.0, (held_q + add_q) / new_q), 6) + if _rid is not None: + data["_applied_entry_price"] = float(record.entry_price) + data["_applied_quantity"] = int(record.quantity) + if _rp_changed: + data["_applied_remaining_pct"] = float(record.remaining_pct) + data["_target_id"] = _rid + _new_high = data.get("highest_since_entry") + if _new_high is not None: + _old_high = float(getattr(record, "highest_since_entry", 0) or 0) + record.highest_since_entry = max(_old_high, float(_new_high)) + return frozenset(skip) + + adj = data.get("entry_fill_adjust") + if isinstance(adj, dict): + skip.add("entry_price") + if _pinned: + record.entry_price = float(data["_applied_entry_price"]) + return frozenset(skip) + try: + o = float(adj.get("order_price") or 0) + f = float(adj.get("filled_price") or 0) + q = int(adj.get("filled_qty") or 0) + except (TypeError, ValueError): + logger.warning("⚠️ [Worker] entry_fill_adjust 파싱 실패 — 진입가 보정 생략: %r", adj) + return frozenset(skip) + rec_q = int(getattr(record, "quantity", 0) or 0) + cur = float(getattr(record, "entry_price", 0) or 0) + try: + _rp2 = min(1.0, max(0.0, float(getattr(record, "remaining_pct", 1.0) or 1.0))) + except (TypeError, ValueError): + _rp2 = 1.0 + # 평단은 '현재 보유분' 가중이므로 분모도 실보유(원본 × remaining_pct)로 맞춘다. + held_q2 = rec_q * _rp2 + if o > 0 and f > 0 and q > 0 and held_q2 >= 1 and cur > 0: + q_eff = min(q, held_q2) + new_p = cur + (f - o) * q_eff / held_q2 + if new_p > 0: + record.entry_price = round(float(new_p), 2) # Numeric(15,2) + if _rid is not None: + data["_applied_entry_price"] = float(record.entry_price) + data["_target_id"] = _rid + else: + logger.warning( + "⚠️ [Worker] ActiveTrade 체결가 보정 조건 불충족 — 생략 " + "(order=%s filled=%s qty=%s row_qty=%s row_entry=%s)", o, f, q, rec_q, cur, + ) + return frozenset(skip) + + async def _handle_upsert_active_trade(session, data: dict) -> None: """ActiveTrade UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현.""" from app.models.models import ActiveTrade @@ -1265,6 +1469,14 @@ async def _handle_upsert_active_trade(session, data: dict) -> None: result = await session.execute(query) record = result.scalars().first() + if record is None and data.get("entry_fill_adjust"): + # 체결가 보정 전용 payload(수량·진입가 없음) — 대상 행이 없으면(이미 청산 등) 할 일이 없다. + logger.info( + "ℹ️ [Worker] ActiveTrade 체결가 보정 생략: %s active 행 없음 (strategy=%s)", + stock_code, strategy_name, + ) + return + if record is None: # remaining_pct_multiply·전략명 필터 키는 신규 생성 시 무시 create_data = { @@ -1280,6 +1492,12 @@ async def _handle_upsert_active_trade(session, data: dict) -> None: stock_code, _missing, sorted(create_data.keys()), ) return + if data.get("_retried_exec"): + logger.warning( + "⚠️ [Worker] ActiveTrade 신규 생성이 재시도 실행에서 발생: %s (strategy=%s) — " + "순서 역전/유령 행 가능성, KIS 잔고와 대조 필요", + stock_code, strategy_name, + ) record = ActiveTrade(**create_data) session.add(record) else: @@ -1302,7 +1520,12 @@ async def _handle_upsert_active_trade(session, data: dict) -> None: if _rid is not None: data["_applied_remaining_pct"] = record.remaining_pct data["_target_id"] = _rid + # [2026-10-08] 추가매수(accumulate_entry) / 체결가 보정(entry_fill_adjust) 은 진입가·수량을 + # **기존 행 기준으로 계산**해야 하므로 아래 일반 setattr 루프에서 해당 키를 제외한다. + _skip_keys = _apply_entry_price_adjustments_v2(record, data) for key, val in data.items(): + if key in _skip_keys: + continue # [C1 2026-09-28] 반드시 continue — return 이면 'stock_code' 가 모든 payload 의 # 첫 키라서 기존 active 행에 대한 모든 upsert 가 첫 이터레이션에서 빠져나가 # commit() 에 도달하지 못한다(예외가 없어 성공 처리되고 DLQ 에도 안 남는다). @@ -1329,6 +1552,11 @@ async def _handle_upsert_stock_ban(session, data: dict) -> None: stock_code = data.get("stock_code") consecutive_losses = data.get("consecutive_losses") # stock_bans.ban_until은 tz-naive 컬럼 — tz-aware 값이 들어와도 안전하게 저장. + # [시각 규약 — 의도적] ban_until 은 naive-**UTC** (loss_tracker 가 to_aware_utc 로 읽는다). + # 같은 행의 updated_at 등 다른 테이블 시각은 KST-naive 라 SQL 에서 `ban_until > localtimestamp` + # 처럼 직접 비교하면 9시간 어긋난다 -> `(ban_until AT TIME ZONE 'UTC') > now()` 를 쓸 것. + # 저장만 KST 로 바꾸면 기존 활성 밴이 9시간 지연 해제되고(load 도 같이 바꿔야 함) 마이그레이션이 + # 필요하므로 규약을 유지한다(테스트로 고정: test_db_worker_stock_ban_tz_20261010). ban_until = to_naive_utc(data.get("ban_until")) result = await session.execute(select(StockBan).where(StockBan.stock_code == stock_code)) @@ -1441,6 +1669,11 @@ async def _handle_close_active_trade(session, data: dict) -> None: stock_code, strategy_name, ) + else: + logger.warning( + "⚠️ [Worker] ActiveTrade close 대상 0건: %s (strategy=%s, pinned=%s, retried=%s)", + stock_code, strategy_name, pinned_id, bool(data.get("_retried_exec")), + ) # ───────────────────────────────────────────────────────────────────────────── @@ -1457,6 +1690,8 @@ async def _execute_db_task_impl(task: dict) -> None: task["data"] = dict(task["data"]) task["_data_owned"] = True data = task.get("data") + if task_type in _PINNED_TASK_TYPES and isinstance(data, dict) and task.get("_requeued"): + data["_retried_exec"] = True async with AsyncSessionLocal() as session: # ── 1. 매매일지 INSERT ───────────────────────────────────── @@ -1750,6 +1985,128 @@ async def _drain_on_shutdown_impl( return remaining +def _task_order_keys_v2(task: dict) -> frozenset: + """[2026-10-09] 쓰기 순서 보장 키 — 같은 종목(stock_code)/주문(order_id)의 쓰기는 순서를 지킨다.""" + keys = set() + data = task.get("data") if isinstance(task, dict) else None + if isinstance(data, dict): + if data.get("stock_code"): + keys.add(("s", data["stock_code"])) + if data.get("order_id"): + keys.add(("o", data["order_id"])) + return frozenset(keys) + + +class _OrderGateV2: + """지연 재투입 중인 태스크와 같은 키의 뒤 태스크를 보류해 쓰기 순서를 유지한다. + + 재시도가 큐 꼬리로 재투입되면 그 사이 뒤 태스크(close 등)가 먼저 실행돼, 실패했던 진입 + upsert 가 나중에 '행 없음 → 신규 생성' 으로 이미 청산된 포지션의 유령 active 행을 만든다. + """ + + def __init__(self) -> None: + self._deferred: dict[int, frozenset] = {} # id(task) -> keys (재시도 대기) + self._released: dict[int, frozenset] = {} # id(task) -> keys (풀려났으나 아직 미완료) + self._held: list[dict] = [] + + def defer(self, task: dict) -> None: + task["_requeued"] = True + self._released.pop(id(task), None) + self._deferred[id(task)] = _task_order_keys_v2(task) + + def should_hold(self, task: dict) -> bool: + tid = id(task) + if tid in self._deferred: + return False + keys = _task_order_keys_v2(task) + if not keys: + return False + if tid in self._released: + # 풀려난 태스크: 앞선 같은 키 태스크가 다시 재시도 대기에 들어갔다면 앞지르지 않도록 보류 + blocked = any(keys & k for k in self._deferred.values()) + if blocked: + self._released.pop(tid, None) + return blocked + # 재시도 대기 중이거나 풀려났으나 아직 끝나지 않은 같은 키 태스크가 있으면 그 뒤로 보류 + return (any(keys & k for k in self._deferred.values()) + or any(keys & k for k in self._released.values())) + + def hold(self, task: dict) -> None: + self._held.append(task) + + def held_tasks(self) -> list: + return list(self._held) + + def resolve(self, task: dict) -> list: + """태스크가 성공/DLQ 로 끝났을 때 호출. 이제 막힌 곳이 없는 보류 태스크를 순서대로 반환. + + 반환된 태스크는 실행이 끝날 때까지 '진행 중' 키로 남아, 큐 꼬리에 뒤따라 들어온 + 같은 키의 후속 태스크가 앞지르지 못한다. + """ + a = self._deferred.pop(id(task), None) + b = self._released.pop(id(task), None) + if a is None and b is None: + return [] + released, still = [], [] + for h in self._held: + hk = _task_order_keys_v2(h) + blocked = (any(hk & k for k in self._deferred.values()) + or any(hk & k for k in self._released.values())) + if blocked: + still.append(h) + else: + released.append(h) + self._released[id(h)] = hk + self._held = still + return released + + +async def _requeue_after_v2(queue: asyncio.Queue, task: dict, delay: float, backoff: list) -> None: + """워커를 막지 않는 지연 재투입. 취소되면 task 는 backoff 목록에 남아 종료 drain 이 처리한다.""" + await asyncio.sleep(delay) + # remove + put_nowait 사이에 await 가 없어 drain 과 경합하지 않는다 + if task in backoff: + backoff.remove(task) + queue.put_nowait(task) + # [R5] 실패 시 task_done() 을 미뤄 unfinished 를 유지했다 — 재투입분이 +1 이므로 원래 몫을 여기서 상쇄 + queue.task_done() + + +def _release_held_v2(queue: asyncio.Queue, task: dict) -> None: + """보류분 재투입. 보류 시 task_done() 을 미뤘으므로 재투입 +1 을 상쇄해 join() 이 보류 중 풀리지 않게 한다.""" + queue.put_nowait(task) + queue.task_done() + + +async def _dlq_runtime_replay_loop_v2(queue: asyncio.Queue) -> None: + """[2026-10-09] 런타임 DLQ 주기 재처리. 재기동 전까지 DLQ 가 방치되던 것을 막는다. + + DB 가 살아 있음을 SELECT 1 로 확인하고, 회로가 열려 있거나 큐가 많이 적체되면 건너뛴다. + 재큐잉 자체는 replay_dlq (큐 경유, 충돌·나이 판정 포함)에 맡긴다. + """ + from sqlalchemy import text + while True: + try: + await asyncio.wait_for(asyncio.Event().wait(), timeout=DLQ_RUNTIME_REPLAY_INTERVAL_SEC) + except asyncio.TimeoutError: + pass + try: + if _worker_state["circuit_open"] or queue.qsize() > WORKER_QUEUE_ALERT_DEPTH: + continue + counts = await dlq_status_counts() + if counts.get("pending", 0) <= 0: + continue + # DB-READ-EXCEPTION: 헬스 프로브 SELECT 1 (db_worker 소유, 쓰기 없음) + async with AsyncSessionLocal() as _s: + await _s.execute(text("SELECT 1")) + n = await replay_dlq() + logger.warning("[DLQ] 런타임 재처리: pending=%d → 재큐잉 %d건", counts["pending"], n) + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning("[DLQ] 런타임 재처리 건너뜀(DB 미복구 또는 오류): %s", e) + + async def db_writer_worker() -> None: """lifespan 에서 asyncio.create_task() 로 실행되는 영구 백그라운드 워커. @@ -1769,13 +2126,32 @@ async def db_writer_worker() -> None: inflight: dict | None = None backoff: list[dict] = [] pending_saves: set = set() + timers: set = set() + circuit_since: float | None = None + _worker_state.update(consecutive_failures=0, circuit_open=False) + gate = _OrderGateV2() + replay_task = asyncio.ensure_future(_dlq_runtime_replay_loop_v2(queue)) try: while True: task = await queue.get() + if gate.should_hold(task): + # 같은 종목/주문의 앞선 쓰기가 재시도 대기 중 — 순서 보장을 위해 그 뒤로 보류 + # task_done() 을 미룬다(unfinished 유지) — 재투입 시 _release_held_v2 가 상쇄 + gate.hold(task) + continue inflight = task + _deferred_retry = False try: + _t0 = time.monotonic() await _execute_db_task_impl(task) inflight = None + for _rel in gate.resolve(task): + _release_held_v2(queue, _rel) + _worker_state.update( + consecutive_failures=0, circuit_open=False, last_success_ts=time.time(), + ) + circuit_since = None + _check_queue_backlog_v2(queue, time.monotonic() - _t0) except asyncio.CancelledError: raise except Exception as exc: @@ -1785,17 +2161,53 @@ async def db_writer_worker() -> None: task_type = task.get("type", "unknown") _strip_retry_markers(task) # [M-A] 실패 실행의 절대값 마커는 재사용 금지 - if retry_count < max_retries: + _worker_state["consecutive_failures"] += 1 + if _worker_state["consecutive_failures"] >= CIRCUIT_OPEN_FAILURES: + if circuit_since is None: + circuit_since = time.monotonic() + logger.error( + "❌ [Worker] 연속 %d회 실패 — DB 장애로 간주(회로 열림), 재시도 횟수 소모 보류", + _worker_state["consecutive_failures"], + ) + _worker_state["circuit_open"] = True + _circuit_hold = ( + circuit_since is not None + and time.monotonic() - circuit_since < CIRCUIT_MAX_HOLD_SEC + ) + if queue.qsize() >= WORKER_QUEUE_ALERT_DEPTH: + _alert_throttled_v2( + "db_worker:queue_depth", "DB_WORKER", + f"DB 쓰기 큐 적체 qsize={queue.qsize()} (연속 실패 " + f"{_worker_state['consecutive_failures']}회). DB 상태 확인 필요.", + ) + if _circuit_hold: + # 장애 구간: 재시도 횟수를 소모하지 않고 일괄 대기 후 재투입(DLQ 조기 유실 방지) + delay = CIRCUIT_RETRY_DELAY_SEC + logger.warning( + "⚠️ [Worker] 회로 열림 — type=%s 재시도 횟수 유지(%d), %.0fs 후 재투입: %s", + task_type, retry_count, delay, exc, + ) + gate.defer(task) + _deferred_retry = True + _t = asyncio.ensure_future(_requeue_after_v2(queue, task, delay, backoff)) + backoff.append(task) + timers.add(_t) + _t.add_done_callback(timers.discard) + elif retry_count < max_retries: task["_retry_count"] = retry_count + 1 delay = min(2 ** retry_count, 8) # 1, 2, 4초 지수 백오프 logger.warning( "⚠️ [Worker] DB task retry %d/%d (type=%s): %s — %ds 후 재시도", retry_count + 1, max_retries, task_type, exc, delay, ) - backoff.append(task) # 백오프 중 cancel 되면 drain 이 이 목록을 먼저 처리 - await asyncio.sleep(delay) - backoff.remove(task) - await queue.put(task) # 큐에 재등록 + # 워커를 막지 않고 지연 재투입 — 그 사이 다른 태스크 계속 처리. + # backoff 목록: 취소 시 drain 이 이 목록을 먼저 처리 + backoff.append(task) + gate.defer(task) + _deferred_retry = True + _t = asyncio.ensure_future(_requeue_after_v2(queue, task, delay, backoff)) + timers.add(_t) + _t.add_done_callback(timers.discard) else: logger.error( "❌ [Worker] DB task DROPPED after %d retries (type=%s): %s", @@ -1807,13 +2219,20 @@ async def db_writer_worker() -> None: pending_saves.add(fut) fut.add_done_callback(pending_saves.discard) await asyncio.shield(fut) + for _rel in gate.resolve(task): + _release_held_v2(queue, _rel) finally: - queue.task_done() + if not _deferred_retry: + queue.task_done() except asyncio.CancelledError: # 큐 drain: 종료 전 남은 태스크 처리하여 데이터 손실 방지 + for _t in list(timers): + _t.cancel() + replay_task.cancel() _drain_started = time.monotonic() remaining = await _drain_on_shutdown_impl( - queue, inflight, backoff, pending_saves, drain_started=_drain_started + queue, inflight, backoff + [h for h in gate.held_tasks() if h not in backoff], + pending_saves, drain_started=_drain_started, ) # [M-D] 이후 투입은 DLQ 직행. drain 과 표식 사이에 들어온 건은 아래에서 한 번 더 비운다. setattr(queue, _QUEUE_CLOSED_ATTR, True) @@ -1824,3 +2243,5 @@ async def db_writer_worker() -> None: if remaining: logger.info(f"[DBWorker] 종료 시 {remaining}건 drain 완료") logger.info("⏹️ DB Writer Worker stopped") + finally: + replay_task.cancel() diff --git a/backend/app/services/execution_quality.py b/backend/app/services/execution_quality.py new file mode 100644 index 0000000..8c87e84 --- /dev/null +++ b/backend/app/services/execution_quality.py @@ -0,0 +1,77 @@ +"""실행 품질(슬리피지 bp·체결률) 집계 — 순수 계산 모듈 (settings/DB/네트워크 참조 없음). + +TradeHistory.slippage 는 (체결가 − 주문가)/주문가×100 [%], side 무관 원시값이다 +(trade_executor.slippage_cost_bp_v2 주석). BUY/SELL 을 그대로 평균하면 상쇄되므로 +side 보정(불리 = +) bp 로 바꿔 집계한다. 보정식은 slippage_cost_bp_v2 와 동일하며 +(trade_executor 는 import 비용이 커서 복제) 동일성은 테스트로 고정한다. +""" +from __future__ import annotations + +import math +from collections import defaultdict +from typing import Any, Iterable, Mapping + +LOW_SAMPLE_THRESHOLD = 30 # n < 30 이면 low_sample + + +def slippage_cost_bp_v3(order_type: str, slippage_pct) -> float | None: + """원시 슬리피지(%) → side 보정 비용(bp, 불리 = +). 해석 불가면 None.""" + try: + val = float(slippage_pct) + except (TypeError, ValueError): + return None + if not math.isfinite(val): + return None + side = str(order_type or "").upper() + if side == "BUY": + return val * 100.0 + if side == "SELL": + return -val * 100.0 + return None + + +def _hour_of(row: Mapping[str, Any]): + ts = row.get("executed_at") or row.get("created_at") + return getattr(ts, "hour", None) + + +def _bucket_stats(rows: list, min_n: int) -> dict: + costs: list[float] = [] + for r in rows: + c = slippage_cost_bp_v3(r.get("order_type"), r.get("slippage")) + if c is not None: + costs.append(c) + n_all = len(rows) + n = len(costs) + verified = [r.get("fill_verified") for r in rows if r.get("fill_verified") is not None] + filled = sum(1 for r in rows if (r.get("executed_quantity") or 0) > 0) + return { + "n_orders": n_all, + "n_slippage": n, + "fill_rate": round(filled / n_all, 4) if n_all else None, + "mean_cost_bp": round(sum(costs) / n, 3) if n else None, + "mean_abs_bp": round(sum(abs(c) for c in costs) / n, 3) if n else None, + "fill_verified_ratio": round(sum(1 for v in verified if v) / len(verified), 4) if verified else None, + "low_sample": n < min_n, + } + + +def aggregate_execution_quality_v1( + rows: Iterable[Mapping[str, Any]], min_n: int = LOW_SAMPLE_THRESHOLD +) -> dict: + """행(dict: order_type, slippage, strategy_type, executed_quantity, executed_at, fill_verified)을 + 전체/전략별/시간대별로 집계한다. slippage NULL 행은 n_orders 에만 포함된다.""" + rows = list(rows) + by_strategy: dict[str, list] = defaultdict(list) + by_hour: dict[int, list] = defaultdict(list) + for r in rows: + by_strategy[str(r.get("strategy_type") or "unknown")].append(r) + h = _hour_of(r) + if h is not None: + by_hour[int(h)].append(r) + return { + "overall": _bucket_stats(rows, min_n), + "by_strategy": {k: _bucket_stats(v, min_n) for k, v in sorted(by_strategy.items())}, + "by_hour": {h: _bucket_stats(v, min_n) for h, v in sorted(by_hour.items())}, + "min_n": min_n, + } diff --git a/backend/app/services/kis_realtime_stream.py b/backend/app/services/kis_realtime_stream.py index 5047da1..5ff3a3b 100644 --- a/backend/app/services/kis_realtime_stream.py +++ b/backend/app/services/kis_realtime_stream.py @@ -14,6 +14,7 @@ KIS WebSocket 실시간 시세 수신 서비스 - lifespan()에서 asyncio.create_task()로 기동 """ import asyncio +import contextlib import json import logging import time @@ -27,19 +28,62 @@ except ImportError: try: import websockets - from websockets.exceptions import ConnectionClosed, InvalidStatusCode + from websockets.exceptions import ConnectionClosed except ImportError: websockets = None # type: ignore ConnectionClosed = Exception # type: ignore - InvalidStatusCode = Exception # type: ignore + +# [L-9] 핸드셰이크 비-101 응답 예외: legacy(<14) InvalidStatusCode / 신 asyncio 클라이언트(>=14) InvalidStatus +_handshake_excs: list = [] +try: + from websockets.exceptions import InvalidStatus as _InvalidStatus # websockets >= 10 + _handshake_excs.append(_InvalidStatus) +except ImportError: + pass # harness: allow-except-pass 구버전 websockets 에는 InvalidStatus 가 없다(아래 InvalidStatusCode 사용) +InvalidStatusCode = None # type: ignore +_WS_MAJOR_AT_IMPORT = ( + int(str(websockets.__version__).split(".")[0]) + if websockets is not None and str(getattr(websockets, "__version__", "")).split(".")[0].isdigit() + else 0 +) +if websockets is not None and _WS_MAJOR_AT_IMPORT < 14: + # legacy 클라이언트(<14)만 InvalidStatusCode 를 던진다. 14+ 에서 import 하면 DeprecationWarning. + try: + from websockets.exceptions import InvalidStatusCode # type: ignore[no-redef] + _handshake_excs.append(InvalidStatusCode) + except ImportError: + InvalidStatusCode = None # type: ignore +_HANDSHAKE_STATUS_ERRORS: tuple = tuple(_handshake_excs) or (Exception,) from app.core.config import settings from app.core.json_helpers import _dumps +from app.core.log_throttle import warn_throttled from app.core.redis import setex_json, TTL_TICK, TTL_QUOTE from app.services.websocket_manager import websocket_manager logger = logging.getLogger(__name__) + +def _websockets_major_version() -> int: + try: + return int(str(getattr(websockets, "__version__", "0")).split(".")[0]) + except (TypeError, ValueError): + return 0 + + +def _ws_header_kwarg_name() -> str: + """[L-9] websockets 14+ 의 기본 connect(신 asyncio 클라이언트)는 `additional_headers`, + 그 이전(legacy)은 `extra_headers` 를 받는다. requirements 12.0 / 로컬 15.x 혼재 대응.""" + return "additional_headers" if _websockets_major_version() >= 14 else "extra_headers" + + +def _handshake_status_code(exc: BaseException): + """InvalidStatusCode.status_code(legacy) / InvalidStatus.response.status_code(신) 양쪽 지원.""" + status = getattr(exc, "status_code", None) + if status is None: + status = getattr(getattr(exc, "response", None), "status_code", None) + return status + # ── KIS WebSocket URL (실전/모의 분기, .env KIS_WS_REAL_URL / KIS_WS_VIRTUAL_URL 오버라이드 가능) ── _KIS_WS_REAL = settings.KIS_WS_REAL_URL _KIS_WS_VIRTUAL = settings.KIS_WS_VIRTUAL_URL @@ -55,10 +99,58 @@ _RECONNECT_WAIT = settings.KIS_RECONNECT_WAIT # 재연결 기본 대기 _RECONNECT_MAX_WAIT = 300 # 최대 재연결 대기 (5분) — 지수 백오프 상한 _APPROVAL_KEY_TTL = settings.KIS_APPROVAL_KEY_TTL # approval_key 캐시 유효시간 (초) +# [H-4] KIS 실시간 등록 한도: 1세션당 41건(체결·호가 각각 1건씩 차감). +_MAX_REGISTRATIONS = int(getattr(settings, "KIS_WS_MAX_REGISTRATIONS", 41)) +# 구독 실패 ACK(rt_cd≠0) 후 같은 (TR, 종목) 재시도까지 대기(초) — 영구 실패의 재전송 폭주 방지 +_ACK_FAIL_RETRY_SEC = 60.0 +# [L-6] 실시간 데이터 처리 타임아웃(초) — Redis/클라이언트 전송이 KIS 수신을 막지 않게 +_REDIS_WRITE_TIMEOUT = 2.0 + +# 레코드당 필드 수 (다건 프레임 분할용, KIS 개발가이드) +_RECORD_FIELD_COUNT = {"H0STCNT0": 46} + # ── 모듈 레벨 approval_key 캐시 ────────────────────────────────────────────── +_STABLE_SESSION_SECS = 60.0 # 이 이상 유지된 세션만 '성공'으로 보고 백오프/streak 리셋 +_NORMAL_CLOSE_REISSUE_STREAK = 3 # 서버 정상 close 연속 N회면 캐시 키를 폐기하고 재발급 +# 안정 세션(>=_STABLE_SESSION_SECS) 뒤 첫 끊김의 재연결 대기(초). KIS 서버가 매시 정각에 +# close frame 없이 연결을 끊는 정시 정리(추정)마다 KIS_RECONNECT_WAIT(30초) 공백이 나던 것을 줄인다. +_STABLE_RECONNECT_WAIT = 1.0 _cached_approval_key: Optional[str] = None _cached_key_expires: float = 0.0 + +# 연결 건전성 통계 (/health 노출용: get_ws_health_v2) +_ws_stats = { + "disconnects": 0, + "top_of_hour_disconnects": 0, # 정각 직후(분 0~2) 끊김 + "last_disconnect_ts": None, # epoch seconds + "last_tick_ts": None, # epoch seconds +} + + +def _record_disconnect_v2(now_ts: Optional[float] = None) -> None: + """끊김 1건 기록. 정각 직후(분 0~2)면 top_of_hour 로도 센다.""" + ts = time.time() if now_ts is None else now_ts + _ws_stats["disconnects"] += 1 + _ws_stats["last_disconnect_ts"] = ts + if time.gmtime(ts).tm_min <= 2: # KST=UTC+9 → 분 단위는 동일 + _ws_stats["top_of_hour_disconnects"] += 1 + + +def get_ws_health_v2() -> dict: + """정시 끊김 횟수·마지막 tick 수신 시각(epoch s)·경과초. main.py /health 에서 합쳐 쓸 수 있다.""" + last = _ws_stats["last_tick_ts"] + out = dict(_ws_stats) + out["last_tick_age_sec"] = None if last is None else round(max(0.0, time.time() - last), 1) + return out + + +def _reconnect_backoff_v2(connection_attempts: int, session_secs: float) -> float: + """재연결 대기. 안정 세션 뒤 첫 끊김은 즉시 재연결, 그 외는 기존 지수 백오프.""" + if session_secs >= _STABLE_SESSION_SECS: + return _STABLE_RECONNECT_WAIT + return min(_RECONNECT_WAIT * (2 ** min(max(connection_attempts, 1) - 1, 6)), + _RECONNECT_MAX_WAIT) _approval_key_lock: Optional[asyncio.Lock] = None @@ -81,7 +173,15 @@ def _create_ssl_context() -> ssl.SSLContext: def _get_ws_url() -> str: # [이슈4 수정] default값이 True이면 True == "virtual" → False → 실전 URL 반환 오류. # KIS_MODE 기본값은 "virtual" 문자열로 지정해야 함. - return _KIS_WS_VIRTUAL if getattr(settings, "KIS_MODE", "virtual") == "virtual" else _KIS_WS_REAL + # 대소문자 무관: 'real' 만 실전 URL. 'Virtual'/'VIRTUAL' 이 실전 WS 로 가는 것을 막는다(안전한 쪽=모의). + mode = str(getattr(settings, "KIS_MODE", "virtual") or "virtual").strip().lower() + return _KIS_WS_REAL if mode == "real" else _KIS_WS_VIRTUAL + + +def _invalidate_cached_approval_key() -> None: + """모듈 캐시의 approval_key 를 만료 처리 → 다음 _fetch_approval_key 가 재발급한다.""" + global _cached_key_expires + _cached_key_expires = 0.0 async def _fetch_approval_key() -> Optional[str]: @@ -183,6 +283,23 @@ def _build_subscribe_msg(approval_key: str, tr_id: str, stock_code: str, }) +def _should_alert_instant_close(total: int) -> bool: + """즉시-close 연속 횟수가 streak 에 처음 도달할 때, 이후 streak*4 배수마다 경보 (스팸 방지).""" + n = _NORMAL_CLOSE_REISSUE_STREAK + return total == n or (total > n and total % (n * 4) == 0) + + +async def _alert_instant_close_v2(total: int) -> None: + try: + from app.services.telegram_notifier import telegram_notifier + await asyncio.wait_for( + telegram_notifier.notify_system_warning( + "KIS_WS", f"실시간 WS 접속 직후 정상 close {total}회 연속 (다른 세션/서버 거부 의심)"), + timeout=10.0) + except Exception as e: + logger.warning(f"[KIS WS] 텔레그램 경보 전송 실패: {e}") + + # ── 수신 데이터 파서 ───────────────────────────────────────────────────────── def _parse_realtime_message(raw: str) -> Optional[dict]: """KIS 실시간 메시지 파싱. @@ -212,8 +329,35 @@ def _parse_realtime_message(raw: str) -> Optional[dict]: return None _enc, tr_id, _cnt, body = parts[0], parts[1], parts[2], parts[3] - fields = body.split("^") + # 다건 프레임(_cnt>1)은 레코드가 '^' 로 이어 붙어 온다. 단건 API 는 마지막(최신)만 반환. + records = _split_records(tr_id, _cnt, body) + return _build_record(tr_id, records[-1] if records else []) + +def _parse_realtime_records_v2(raw: str) -> list: + """다건 프레임의 모든 레코드를 파싱한다 (oldest → newest). + + 프레임 내 종목이 섞여 있어도 앞 종목 tick 이 버려지지 않도록, 호출부가 + (type, stock_code)별 마지막 것을 고른다. JSON/PINGPONG 은 단건 리스트로 반환. + """ + if not raw: + return [] + if raw.startswith("{"): + one = _parse_realtime_message(raw) + return [one] if one else [] + parts = raw.split("|") + if len(parts) < 4: + return [] + tr_id, cnt, body = parts[1], parts[2], parts[3] + out = [] + for fields in _split_records(tr_id, cnt, body): + rec = _build_record(tr_id, fields) + if rec: + out.append(rec) + return out + + +def _build_record(tr_id: str, fields: list) -> Optional[dict]: if tr_id == TR_TICK and len(fields) >= 15: # H0STCNT0 필드 순서 (KIS 개발가이드 기준) # [0]=종목코드 [1]=체결시간 [2]=현재가 [3]=전일대비부호 [4]=전일대비(원) [5]=전일대비율(%) @@ -226,6 +370,10 @@ def _parse_realtime_message(raw: str) -> Optional[dict]: "stock_code": fields[0], "price": _safe_float(fields[2]), "change_rate": _safe_float(fields[5]), + # 당일 시가/고가/저가 — 차트 당일봉 합성이 Redis tick 만으로 가능하도록(M-7) + "open": _safe_float(fields[7]), + "high": _safe_float(fields[8]), + "low": _safe_float(fields[9]), "volume": _safe_int(fields[13]), "trade_amount":_safe_int(fields[14]), "timestamp": fields[1] if len(fields) > 1 else "", @@ -253,6 +401,35 @@ def _parse_realtime_message(raw: str) -> Optional[dict]: return None +def _split_records(tr_id: str, cnt_raw: str, body: str) -> list: + """파이프 포맷 본문을 레코드 리스트로 분할 (oldest → newest). + + KIS 는 체결이 몰리면 한 프레임에 N건(데이터건수 필드)을 '^' 로 이어 보낸다. + 레코드당 필드 수: H0STCNT0 = 46(KIS 개발가이드). 그 외 TR 은 전체 필드 수 / 건수가 + 나누어떨어질 때만 분할하고, 판정 불가면 기존 동작(전체를 1건으로 취급)으로 폴백한다. + """ + fields = body.split("^") + try: + cnt = int(cnt_raw) + except (TypeError, ValueError): + cnt = 1 + if cnt <= 1: + return [fields] + expected = _RECORD_FIELD_COUNT.get(tr_id) + if expected and len(fields) >= expected * cnt: + per = expected + elif len(fields) % cnt == 0: + per = len(fields) // cnt + else: + warn_throttled( + logger, "kis_ws:split_records", + "[KIS WS] 다건 프레임 분할 불가 tr_id=%s cnt=%s fields=%d — 첫 레코드로 폴백", + tr_id, cnt, len(fields), + ) + return [fields] + return [fields[i * per:(i + 1) * per] for i in range(cnt)] + + def _safe_float(v: str) -> Optional[float]: try: return float(v) @@ -283,6 +460,35 @@ async def _cache_realtime_data(msg_type: str, stock_code: str, data: dict) -> No await setex_json(f"quote:{stock_code}", TTL_QUOTE, data) +# ── 구독 대상 계산 (순수 함수) ─────────────────────────────────────────────── +def _is_domestic_code(code: str) -> bool: + """KIS 국내 실시간(H0STCNT0/H0STASP0) 대상 종목코드인지. + + 국내 단축코드는 6자리 영숫자이고 숫자를 반드시 포함한다(신규 코드 예: 0004V0). + 미국 티커(AAPL, BRK.B 등)는 숫자가 없거나 길이가 달라 제외된다. + """ + c = (code or "").strip() + return len(c) == 6 and c.isascii() and c.isalnum() and any(ch.isdigit() for ch in c) + + +def _build_target_registrations(subscribed, selected, budget: int) -> tuple: + """[H-4] (목표 등록 리스트, 한도 초과로 제외된 리스트) 반환. 등록 = (tr_id, 종목코드). + + 우선순위: 선택 종목 체결 > 선택 종목 호가 > 그 외 구독(보유 종목 등) 체결. + 호가(H0STASP0)는 선택 종목(차트를 보고 있는 종목)에만 등록한다. 미국 티커는 제외. + """ + selected_dom = sorted({c for c in (selected or ()) if _is_domestic_code(c)}) + sel_set = set(selected_dom) + others = sorted({c for c in (subscribed or ()) if _is_domestic_code(c)} - sel_set) + wanted = ( + [(TR_TICK, c) for c in selected_dom] + + [(TR_QUOTE, c) for c in selected_dom] + + [(TR_TICK, c) for c in others] + ) + budget = max(0, int(budget)) + return wanted[:budget], wanted[budget:] + + # ── 스트림 클래스 ──────────────────────────────────────────────────────────── class KisRealtimeStream: """KIS WebSocket 실시간 스트림 관리 클래스. @@ -291,23 +497,40 @@ class KisRealtimeStream: run() 진입 → 1단계: _fetch_approval_key() REST 호출 → 2단계: websockets.connect() WebSocket 연결 - → 3단계: _sync_subscriptions() 구독 메시지 전송 - → 4단계: async for raw_msg in ws: 수신 → 파싱 - → Redis SETEX 캐시 (_cache_realtime_data) - → websocket_manager 브로드캐스트 + → 3단계: _sync_subscriptions() 초기 구독 + _sync_loop() 별도 태스크(H-5) + → 4단계: async for raw_msg in ws: 수신 → 파싱 → _enqueue_realtime() + → _dispatch_loop() 가 Redis SETEX + 클라이언트 전송 (수신 루프와 분리, L-6) """ def __init__(self): + # 체결(H0STCNT0) 등록된 종목 — 호환용 뷰. 진실 소스는 _registered. self._subscribed_codes: set[str] = set() + self._registered: set[tuple[str, str]] = set() + self._ack_fail_until: dict[tuple[str, str], float] = {} + self._last_dropped: tuple = () self._ws = None self._running = False self._approval_key: Optional[str] = None self._sync_event: asyncio.Event = asyncio.Event() + # 종목·종류별 최신 데이터 (코얼레싱) — 수신 루프는 넣기만 하고 즉시 다음 프레임을 읽는다 + self._latest: dict[tuple[str, str], dict] = {} + self._dispatch_event: asyncio.Event = asyncio.Event() def notify_subscription_change(self): - """구독 목록 변경 시 즉시 KIS 동기화 트리거 (set_subscriptions 호출 직후 사용)""" + """구독 목록 변경 시 즉시 KIS 동기화 트리거 (subscribe/unsubscribe/set_subscriptions 직후)""" self._sync_event.set() + def _refresh_code_view(self) -> None: + self._subscribed_codes = {c for tr, c in self._registered if tr == TR_TICK} + + def _reset_connection_state(self) -> None: + """[H-3] 연결 종료(정상 close 포함) 시 반드시 호출 — 새 세션은 등록 0건에서 시작한다.""" + self._ws = None + self._registered.clear() + self._subscribed_codes.clear() + self._ack_fail_until.clear() + self._last_dropped = () + async def _ensure_approval_key(self) -> bool: """approval_key 확보. 없으면 REST API 호출.""" if not self._approval_key or time.monotonic() >= _cached_key_expires: @@ -317,40 +540,183 @@ class KisRealtimeStream: return False return True + async def _send_registration(self, ws, tr_id: str, code: str, tr_type: str) -> None: + msg = _build_subscribe_msg(self._approval_key, tr_id, code, tr_type) + await ws.send(msg) + await asyncio.sleep(0.05) # rate-limit 방지 + async def _send_subscribe(self, ws, code: str, tr_type: str = "1"): - """단일 종목 구독/해제 메시지 전송 (체결 + 호가).""" + """단일 종목 구독/해제 메시지 전송 (체결 + 호가). (호환 유지 — 동기화는 _send_registration 사용)""" if not self._approval_key: return for tr_id in (TR_TICK, TR_QUOTE): - msg = _build_subscribe_msg(self._approval_key, tr_id, code, tr_type) - await ws.send(msg) - await asyncio.sleep(0.05) # rate-limit 방지 + await self._send_registration(ws, tr_id, code, tr_type) async def _sync_subscriptions(self, ws): - """websocket_manager 구독 목록과 KIS 구독 상태를 동기화.""" - target = websocket_manager.get_all_subscribed_codes() - to_add = target - self._subscribed_codes - to_remove = self._subscribed_codes - target + """websocket_manager 구독 목록과 KIS 등록 상태를 동기화. - for code in to_add: - logger.info(f"📡 KIS 구독 추가: {code}") - await self._send_subscribe(ws, code, "1") - for code in to_remove: - logger.info(f"🚫 KIS 구독 해제: {code}") - await self._send_subscribe(ws, code, "2") + [H-4] 해지를 추가보다 먼저 보내 한도 슬롯을 확보하고, 세션 한도(_MAX_REGISTRATIONS)를 + 넘으면 우선순위대로 자른다. 구독 실패 ACK 로 백오프 중인 등록은 건너뛴다. + """ + if not self._approval_key: + return + subscribed = websocket_manager.get_all_subscribed_codes() + selected = websocket_manager.get_selected_codes_v2() + targets, dropped = _build_target_registrations(subscribed, selected, _MAX_REGISTRATIONS) + if dropped and tuple(dropped) != self._last_dropped: + logger.warning( + f"⚠️ KIS 실시간 등록 한도 {_MAX_REGISTRATIONS}건 초과 — {len(dropped)}건 제외: " + f"{[f'{tr}:{c}' for tr, c in dropped[:10]]}" + ) + self._last_dropped = tuple(dropped) - self._subscribed_codes = target.copy() + now = time.monotonic() + for k in [k for k, until in self._ack_fail_until.items() if until <= now]: + self._ack_fail_until.pop(k, None) + + target_set = set(targets) + to_remove = sorted(r for r in self._registered if r not in target_set) + to_add = [r for r in targets if r not in self._registered and r not in self._ack_fail_until] + + for tr_id, code in to_remove: + logger.info(f"🚫 KIS 구독 해제: {tr_id} {code}") + await self._send_registration(ws, tr_id, code, "2") + self._registered.discard((tr_id, code)) + for tr_id, code in to_add: + if len(self._registered) >= _MAX_REGISTRATIONS: + break + logger.info(f"📡 KIS 구독 추가: {tr_id} {code}") + await self._send_registration(ws, tr_id, code, "1") + self._registered.add((tr_id, code)) + self._refresh_code_view() + + async def _sync_loop(self, ws) -> None: + """[H-5] 구독 동기화 전용 태스크 — 수신 프레임이 없어도 변경 즉시/주기적으로 동기화.""" + while True: + try: + await asyncio.wait_for(self._sync_event.wait(), timeout=_SYNC_INTERVAL) + except asyncio.TimeoutError: + pass # harness: allow-except-pass 주기 동기화 타이머 만료 — 정상 경로 + self._sync_event.clear() + try: + await self._sync_subscriptions(ws) + except ConnectionClosed: + return + except Exception as e: + logger.warning(f"⚠️ KIS 구독 동기화 실패: {type(e).__name__}: {e}") + + def _handle_ack(self, data: dict) -> None: + """구독/해지 ACK 처리. [H-4] rt_cd≠0 이면 warning + 해당 등록을 빼서 재시도 가능하게.""" + hdr = data.get("header") or {} + body = data.get("body") or {} + tr_id = hdr.get("tr_id") + if not tr_id: + return + tr_key = hdr.get("tr_key") + rt_cd = str(body.get("rt_cd", "")).strip() + msg1 = str(body.get("msg1", "") or "") + msg_up = msg1.upper() + if rt_cd == "0" or "ALREADY IN SUBSCRIBE" in msg_up: + logger.debug(f"[KIS ACK] tr_id={tr_id} tr_key={tr_key} rt_cd={rt_cd} {msg1}") + return + if "UNSUBSCRIBE" in msg_up: + logger.warning(f"⚠️ [KIS ACK] 해지 실패 tr_id={tr_id} tr_key={tr_key} " + f"rt_cd={rt_cd} msg_cd={body.get('msg_cd')} {msg1}") + return + key = (tr_id, tr_key) + if tr_key and key in self._registered: + self._registered.discard(key) + self._ack_fail_until[key] = time.monotonic() + _ACK_FAIL_RETRY_SEC + self._refresh_code_view() + logger.warning(f"⚠️ [KIS ACK] 구독 실패 tr_id={tr_id} tr_key={tr_key} rt_cd={rt_cd} " + f"msg_cd={body.get('msg_cd')} {msg1} — {_ACK_FAIL_RETRY_SEC:.0f}초 후 재시도") + + # ── 실시간 데이터 분배 (L-6) ────────────────────────────────────────────── + def _enqueue_realtime(self, msg_type: str, stock_code: str, parsed: dict) -> None: + self._latest[(msg_type, stock_code)] = parsed + _ws_stats["last_tick_ts"] = time.time() + self._dispatch_event.set() + + async def _dispatch_one(self, msg_type: str, stock_code: str, parsed: dict) -> None: + try: + await asyncio.wait_for( + _cache_realtime_data(msg_type, stock_code, parsed), timeout=_REDIS_WRITE_TIMEOUT + ) + except asyncio.TimeoutError: + warn_throttled(logger, "kis_ws:redis_timeout", + "[KIS WS] Redis 캐시 %.1fs 초과 (%s %s)", _REDIS_WRITE_TIMEOUT, msg_type, stock_code) + except Exception as e: + warn_throttled(logger, "kis_ws:redis_error", + "[KIS WS] Redis 캐시 실패 (%s %s): %s", msg_type, stock_code, e) + try: + await websocket_manager.send_market_update(stock_code, parsed) + except Exception as e: + warn_throttled(logger, "kis_ws:send_error", + "[KIS WS] 클라이언트 전송 실패 (%s): %s", stock_code, e) + + async def _dispatch_once(self) -> int: + batch, self._latest = self._latest, {} + if batch: + await asyncio.gather( + *(self._dispatch_one(mt, code, p) for (mt, code), p in batch.items()) + ) + return len(batch) + + async def _dispatch_loop(self) -> None: + while True: + await self._dispatch_event.wait() + self._dispatch_event.clear() + await self._dispatch_once() + + async def _on_raw_message(self, ws, raw_msg) -> None: + if isinstance(raw_msg, (bytes, bytearray)): + raw_msg = raw_msg.decode("utf-8", "replace") + if not isinstance(raw_msg, str) or raw_msg.startswith("{"): + parsed_list = [_parse_realtime_message(raw_msg)] + else: + parsed_list = _parse_realtime_records_v2(raw_msg) + # (type, code)별 마지막(최신) 레코드만 enqueue — 종목이 섞인 프레임에서도 tick 보존 + latest: dict = {} + for parsed in parsed_list: + if not parsed: + continue + msg_type = parsed.get("_type") + if msg_type in ("tick", "quote"): + code = parsed.get("stock_code", "") + if code: + latest[(msg_type, code)] = parsed + continue + await self._handle_non_data_v2(ws, raw_msg, parsed) + for (mt, code), parsed in latest.items(): + self._enqueue_realtime(mt, code, parsed) + + async def _handle_non_data_v2(self, ws, raw_msg, parsed) -> None: + msg_type = parsed.get("_type") + if msg_type == "pingpong": + await ws.send(raw_msg) + return + if msg_type == "json": + self._handle_ack(parsed.get("data") or {}) + return + + async def _run_session(self, ws) -> None: + """연결 1회분: 초기 구독 → 동기화 태스크 기동 → 수신 루프. 반환 = 서버가 정상 close.""" + self._ws = ws + await self._sync_subscriptions(ws) + sync_task = asyncio.create_task(self._sync_loop(ws)) + try: + async for raw_msg in ws: + await self._on_raw_message(ws, raw_msg) + finally: + sync_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await sync_task async def run(self): """메인 스트림 루프 (재연결 포함). 1단계 → 2단계 → 3단계 → 4단계 반복. - 연결 끊김/오류 시 approval_key 재발급 후 재연결. - - 주의: KIS WebSocket이 응답하지 않으면 일부 설정을 확인하십시오: - - .env: KIS_WS_VIRTUAL_URL / KIS_WS_REAL_URL (포트, 경로) - - KIS 모드: KIS_MODE (virtual/real) - - 재시도: 최대 5회 후 경고만 표시 (무한 루프 방지) + 연결 끊김/오류/정상 close 시 등록 상태를 비우고(H-3) approval_key 재발급 후 재연결. """ if websockets is None: logger.error("❌ websockets 패키지 미설치. pip install websockets") @@ -359,179 +725,155 @@ class KisRealtimeStream: self._running = True ws_url = _get_ws_url() logger.info(f"🔌 KIS 실시간 스트림 시작 (URL: {ws_url})") - + connection_attempts = 0 + normal_close_streak = 0 + instant_close_total = 0 max_connection_attempts = 5 # 5회 실패 후 경고만 + dispatch_task = asyncio.create_task(self._dispatch_loop()) - while self._running: - # ── 1단계: approval_key 발급 ────────────────────────────────── - if not await self._ensure_approval_key(): - logger.warning(f"⏳ approval_key 미확보. {_RECONNECT_WAIT}초 후 재시도...") - await asyncio.sleep(_RECONNECT_WAIT) - continue + try: + while self._running: + # ── 1단계: approval_key 발급 ────────────────────────────────── + if not await self._ensure_approval_key(): + logger.warning(f"⏳ approval_key 미확보. {_RECONNECT_WAIT}초 후 재시도...") + await asyncio.sleep(_RECONNECT_WAIT) + continue - # ── 2단계: WebSocket 연결 ──────────────────────────────────── - try: - ssl_context = _create_ssl_context() - - # WebSocket URL: KIS 공식 WebSocket은 경로 없이 루트(/)로 연결 - # config에 이미 /websocket 등 경로가 포함되어 있으면 그대로 사용 - ws_full_url = ws_url - - if connection_attempts < max_connection_attempts: - logger.info(f"🚀 KIS WS 접속 시도 #{connection_attempts + 1}: {ws_full_url} (key_len={len(self._approval_key) if self._approval_key else 0})") - else: - if connection_attempts == max_connection_attempts: + # ── 2단계: WebSocket 연결 ──────────────────────────────────── + _session_secs = 0.0 + try: + ws_full_url = ws_url + if connection_attempts < max_connection_attempts: + logger.info(f"🚀 KIS WS 접속 시도 #{connection_attempts + 1}: {ws_full_url} " + f"(key_len={len(self._approval_key) if self._approval_key else 0})") + elif connection_attempts == max_connection_attempts: logger.warning( - f"⚠️ KIS WS {max_connection_attempts}회 연결 실패. 앞으로 30초마다 시도합니다.\n" + f"⚠️ KIS WS {max_connection_attempts}회 연결 실패. 백오프하며 계속 시도합니다.\n" f" 확인 사항:\n" f" 1. .env에서 KIS_WS_VIRTUAL_URL / KIS_WS_REAL_URL 정확한가?\n" f" 2. KIS_MODE가 'virtual' 또는 'real'로 설정되었나?\n" f" 3. 네트워크에서 {ws_full_url}에 접근 가능한가?\n" - f" 4. KIS 서버가 WebSocket을 지원하나? (REST API만 지원할 수도 있음)\n" ) - logger.debug(f"🔄 KIS WS 재연결 시도 #{connection_attempts + 1}...") - # Allow optional override via settings, otherwise derive an Origin from the ws url - origin = getattr(settings, "KIS_WS_ORIGIN", None) - if not origin: - try: + origin = getattr(settings, "KIS_WS_ORIGIN", None) + if not origin: if ws_full_url.startswith("wss://"): origin = "https://" + ws_full_url[len("wss://"):].split("/")[0] elif ws_full_url.startswith("ws://"): origin = "http://" + ws_full_url[len("ws://"):].split("/")[0] - except Exception: - origin = None - # 헤더에 Origin 반드시 포함 - extra_headers = { - "Origin": "https://securities.koreainvestment.com", - "User-Agent": "Mozilla/5.0" - } - if origin: - extra_headers["Origin"] = origin - logger.debug(f"📡 WebSocket extra headers: {extra_headers}") - try: - # Optional subprotocols can be set via settings.KIS_WS_SUBPROTOCOLS + extra_headers = { + "Origin": origin or "https://securities.koreainvestment.com", + "User-Agent": "Mozilla/5.0", + } subprotocols = getattr(settings, "KIS_WS_SUBPROTOCOLS", None) if isinstance(subprotocols, str): subprotocols = [s.strip() for s in subprotocols.split(",") if s.strip()] if subprotocols: logger.info(f"🧩 Attempting WebSocket subprotocols: {subprotocols}") - ws_connect_kwargs = dict( - ping_interval=30, - ping_timeout=10, - close_timeout=5, - #ssl=ssl_context, - extra_headers=extra_headers, - ) + ws_connect_kwargs = dict(ping_interval=30, ping_timeout=10, close_timeout=5) + # [L-9] websockets 버전별 헤더 인자명 + ws_connect_kwargs[_ws_header_kwarg_name()] = extra_headers if subprotocols: ws_connect_kwargs["subprotocols"] = subprotocols - async with websockets.connect(ws_full_url, **ws_connect_kwargs) as ws: - self._ws = ws - connection_attempts = 0 # Reset on success - logger.info("✅ KIS WebSocket 연결 완료") - - # ── 3단계: 초기 구독 ────────────────────────────────── - await self._sync_subscriptions(ws) - last_sync = time.monotonic() - - # ── 4단계: 데이터 수신 루프 ────────────────────────── - async for raw_msg in ws: - # 주기적 구독 동기화 (10초 주기 OR 구독 변경 즉시) - if self._sync_event.is_set() or time.monotonic() - last_sync >= _SYNC_INTERVAL: - self._sync_event.clear() - await self._sync_subscriptions(ws) - last_sync = time.monotonic() - - parsed = _parse_realtime_message(raw_msg) - if not parsed: - continue - - msg_type = parsed.get("_type") - - # PINGPONG → pong 응답 - if msg_type == "pingpong": - await ws.send(raw_msg) - continue - - # ACK 로그 (구독 응답 등) - if msg_type == "json": - hdr = parsed.get("data", {}).get("header", {}) - if hdr.get("tr_id"): - logger.debug(f"[KIS ACK] tr_id={hdr['tr_id']} " - f"rt_cd={parsed['data'].get('body', {}).get('rt_cd')}") - continue - - # 체결/호가 → Redis 캐시 + 클라이언트 브로드캐스트 - if msg_type in ("tick", "quote"): - stock_code = parsed.get("stock_code", "") - if stock_code: - # 1) Redis SETEX 캐시 (실패해도 non-blocking) - await _cache_realtime_data(msg_type, stock_code, parsed) - # 2) WebSocket 클라이언트 브로드캐스트 - await websocket_manager.send_market_update(stock_code, parsed) - except InvalidStatusCode as isc: - # WebSocket handshake returned a non-101 HTTP response (often 200) - connection_attempts += 1 - status = getattr(isc, 'status_code', None) - # 진단 로그는 처음 3회만 출력 (로그 도배 방지) - if connection_attempts <= 3: - logger.error(f"❌ KIS WS InvalidStatusCode: HTTP {status}") - if _httpx is not None: + try: + async with websockets.connect(ws_full_url, **ws_connect_kwargs) as ws: + # 연결 성공만으로 attempts 를 리셋하지 않는다 — 접속 직후 close 가 + # 반복되면 백오프가 5초에 고정돼 approval 재발급이 폭주한다. + logger.info("✅ KIS WebSocket 연결 완료") + _session_t0 = time.monotonic() try: - http_url = ws_full_url.replace('wss://', 'https://') - logger.info(f"🔍 Performing HTTP GET diagnostic to {http_url}") - async with _httpx.AsyncClient(timeout=10.0, verify=True) as client: - diag_headers = {"User-Agent": "Mozilla/5.0"} - if origin: - diag_headers["Origin"] = origin - resp = await client.get(http_url, headers=diag_headers) - body_snippet = (resp.text or '')[:200] - logger.error(f"🔍 Diagnostic HTTP {resp.status_code} body={body_snippet}") - except Exception as diag_exc: - logger.error(f"🔍 Diagnostic HTTP GET failed: {diag_exc}") - elif connection_attempts == 4: - logger.warning( - f"⚠️ KIS WS 서버 비정상 (HTTP {status} 반환). " - f"5분 간격으로 재시도합니다. REST API는 정상 동작합니다." - ) - # reset ws state - self._ws = None - self._subscribed_codes.clear() + await self._run_session(ws) + finally: + _session_secs = time.monotonic() - _session_t0 + if _session_secs >= _STABLE_SESSION_SECS: + normal_close_streak = 0 # 충분히 유지된 세션 → 연속 즉시-close 아님 + instant_close_total = 0 + # [H-3] 서버가 정상 close(ConnectionClosedOK) → async for 가 예외 없이 끝남 + connection_attempts += 1 + normal_close_streak += 1 + logger.warning("⚡ KIS WS 서버가 연결을 정상 종료(close) — 재연결합니다.") + _record_disconnect_v2() + if _session_secs < _STABLE_SESSION_SECS: + self._approval_key = None + if _session_secs < _STABLE_SESSION_SECS: + instant_close_total += 1 + if normal_close_streak >= _NORMAL_CLOSE_REISSUE_STREAK: + # 재발급은 streak 도달 시 1회만 — 이후 streak 를 되돌려 매 close 마다 호출하지 않는다 + _invalidate_cached_approval_key() + normal_close_streak = 0 + if _should_alert_instant_close(instant_close_total): + logger.error( + f"🚨 KIS WS 접속 직후 정상 close {instant_close_total}회 연속 — " + f"동일 appkey 다른 세션/서버 거부 의심") + await _alert_instant_close_v2(instant_close_total) + except _HANDSHAKE_STATUS_ERRORS as isc: + connection_attempts += 1 + _invalidate_cached_approval_key() + status = _handshake_status_code(isc) + if connection_attempts <= 3: + logger.error(f"❌ KIS WS 핸드셰이크 거부: HTTP {status} ({type(isc).__name__})") + if _httpx is not None: + try: + http_url = ws_full_url.replace("wss://", "https://") + async with _httpx.AsyncClient(timeout=10.0, verify=True) as client: + resp = await client.get(http_url, headers=extra_headers) + logger.error(f"🔍 Diagnostic HTTP {resp.status_code} " + f"body={(resp.text or '')[:200]}") + except Exception as diag_exc: + logger.error(f"🔍 Diagnostic HTTP GET failed: {diag_exc}") + elif connection_attempts == 4: + logger.warning( + f"⚠️ KIS WS 서버 비정상 (HTTP {status} 반환). " + f"백오프하며 재시도합니다. REST API는 정상 동작합니다." + ) + self._approval_key = None + finally: + # [H-3] 어떤 경로로 끝나든 등록 상태를 비운다 → 재연결 후 전량 재구독 + self._reset_connection_state() + + except ConnectionClosed as e: + connection_attempts += 1 + logger.warning(f"⚡ KIS WS 연결 끊김: {e}. 재연결...") + _record_disconnect_v2() + self._reset_connection_state() + if _session_secs < _STABLE_SESSION_SECS: + # 안정 세션 뒤 끊김은 키 문제가 아니다 → 캐시 키 유지(재발급 생략). + # 접속 직후 끊김 반복이면 기존대로 폐기. 핸드셰이크 거부는 별도 분기에서 폐기. + self._approval_key = None + _invalidate_cached_approval_key() + + except asyncio.CancelledError: + logger.info("⏹️ KIS 실시간 스트림 종료 요청") + break + + except Exception as e: + connection_attempts += 1 + if connection_attempts <= max_connection_attempts: + logger.error(f"❌ KIS WS 오류 (시도 #{connection_attempts}): {type(e).__name__}: {e}", + exc_info=True) + else: + logger.error(f"❌ KIS WS 오류 (시도 #{connection_attempts}): {type(e).__name__}: {e}") + self._reset_connection_state() self._approval_key = None - # outer backoff로 이동 (continue 제거) + _invalidate_cached_approval_key() - except ConnectionClosed as e: - connection_attempts += 1 - logger.warning(f"⚡ KIS WS 연결 끊김: {e}. {_RECONNECT_WAIT}초 후 재연결...") - self._ws = None - self._subscribed_codes.clear() - # 재연결 시 approval_key 재발급 - self._approval_key = None - - except asyncio.CancelledError: - logger.info("⏹️ KIS 실시간 스트림 종료 요청") - break - - except Exception as e: - connection_attempts += 1 - logger.error(f"❌ KIS WS 오류 (시도 #{connection_attempts}): {type(e).__name__}: {e}") - if connection_attempts <= max_connection_attempts: - logger.debug(f" 예외 상세: ", exc_info=True) - self._ws = None - self._subscribed_codes.clear() - self._approval_key = None - - if self._running: - # [FIX] 지수 백오프: 연속 실패 시 대기 시간 증가 (KIS IP 차단 방지) - backoff = min(_RECONNECT_WAIT * (2 ** min(connection_attempts - 1, 6)), _RECONNECT_MAX_WAIT) - logger.info(f"⏳ {backoff:.0f}초 후 재연결 (시도 #{connection_attempts})") - await asyncio.sleep(backoff) - - self._running = False - logger.info("🔴 KIS 실시간 스트림 종료") + if _session_secs >= _STABLE_SESSION_SECS: + # 충분히 유지된 세션 뒤의 끊김은 새 장애의 첫 시도로 취급 → 기본 대기 + connection_attempts = 1 + if self._running: + # 지수 백오프: 연속 실패 시 대기 시간 증가 (KIS IP 차단 방지) + backoff = _reconnect_backoff_v2(connection_attempts, _session_secs) + logger.info(f"⏳ {backoff:.0f}초 후 재연결 (시도 #{connection_attempts})") + await asyncio.sleep(backoff) + finally: + dispatch_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await dispatch_task + self._running = False + logger.info("🔴 KIS 실시간 스트림 종료") def stop(self): self._running = False diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 51cb9cb..ba163aa 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -118,6 +118,8 @@ class CircuitBreaker: self.failure_count = 0 self.last_failure_time: Optional[float] = None self.state = CircuitBreakerState.CLOSED + self.open_count = 0 # 누적 OPEN 전이 횟수 (헬스 노출용) + self.last_opened_at: Optional[float] = None def can_request(self) -> bool: """요청 가능 여부 반환.""" @@ -143,11 +145,69 @@ class CircuitBreaker: self.failure_count += 1 self.last_failure_time = time.time() if self.state == CircuitBreakerState.HALF_OPEN or self.failure_count >= self.failure_threshold: + _was_open = self.state == CircuitBreakerState.OPEN self.state = CircuitBreakerState.OPEN logger.warning( f"⚡ Circuit breaker OPEN: {self.failure_count}회 연속 실패. " f"{self.recovery_timeout}초 후 재시도" ) + if not _was_open: + self.open_count += 1 + self.last_opened_at = time.time() + _alert_breaker_open_v2(self) + + def health(self) -> Dict[str, Any]: + """/health 노출용 스냅샷 (순수 조회).""" + return { + "state": getattr(self.state, "value", str(self.state)), + "failure_count": self.failure_count, + "open_count": self.open_count, + "last_opened_at": self.last_opened_at, + } + + +# 경보 태스크 참조 보관 (GC 로 경보가 사라지는 것을 방지) +_breaker_alert_tasks: set = set() + + +def _breaker_alert_done_v2(task: "asyncio.Task") -> None: + _breaker_alert_tasks.discard(task) + try: + if not task.cancelled() and task.exception() is not None: + logger.warning(f"[KIS] circuit breaker 경보 태스크 실패: {task.exception()}") + except Exception as _e: + logger.warning(f"[KIS] circuit breaker 경보 태스크 확인 실패: {_e}") + + +def _alert_breaker_open_v2(breaker: "CircuitBreaker") -> None: + """OPEN 전이 시 error 로그 + 텔레그램 경보(fire-and-forget). 실패해도 요청 경로를 막지 않는다.""" + logger.error( + f"🚨 [KIS] circuit breaker OPEN — 시세·잔고 조회가 {breaker.recovery_timeout}초간 차단된다 " + f"(주문은 차단하지 않음). 누적 {breaker.open_count}회" + ) + try: + loop = asyncio.get_running_loop() + from app.services.telegram_notifier import telegram_notifier + _task = loop.create_task(telegram_notifier.notify_system_warning( + "KIS_CIRCUIT_BREAKER", + f"KIS 조회 차단기 OPEN ({breaker.failure_count}회 연속 실패, {breaker.recovery_timeout}초). " + f"손절 판정용 시세 조회가 지연될 수 있음", + )) + _breaker_alert_tasks.add(_task) + _task.add_done_callback(_breaker_alert_done_v2) + except Exception as _alert_err: + logger.warning(f"[KIS] circuit breaker 경보 전송 실패: {_alert_err}") + + +def get_kis_breaker_health_v2() -> Dict[str, Any]: + """모듈 레벨 KIS 인스턴스들의 breaker 상태 (main /health 에서 호출용).""" + out: Dict[str, Any] = {} + for _name in ("korea_investment_api", "real_market_api"): + _inst = globals().get(_name) + _cb = getattr(_inst, "circuit_breaker", None) + if _cb is not None: + out[_name] = _cb.health() + return out class APICallMonitor: @@ -712,7 +772,9 @@ class KoreaInvestmentAPI: """API 요청 실행 (Circuit Breaker + 세마포어 + 재시도 + 모니터링)""" # Circuit Breaker 상태 확인 - if not self.circuit_breaker.can_request(): + # [R4] 주문 엔드포인트는 breaker 로 차단하지 않는다 — 시세/잔고 조회 장애(EGW00201·타임아웃)가 + # 손절·EOD·MDD 매도까지 막으면 §8.1-11(청산 우선) 위반. 주문 실패는 breaker 에 세지도 않는다. + if endpoint not in self._NON_IDEMPOTENT_ENDPOINTS and not self.circuit_breaker.can_request(): wait_sec = self.circuit_breaker.recovery_timeout logger.warning(f"⚡ Circuit breaker OPEN — {wait_sec}초 후 재시도 가능. 요청 거부: {endpoint}") raise Exception(f"Circuit breaker open: KIS API 일시 차단 ({wait_sec}s)") @@ -752,6 +814,17 @@ class KoreaInvestmentAPI: except Exception: return None + def _record_breaker_failure_v2(self, endpoint: str) -> None: + """주문 엔드포인트 실패는 조회 breaker 에 세지 않는다(주문·조회 장애 분리).""" + if endpoint in self._NON_IDEMPOTENT_ENDPOINTS: + return + self.circuit_breaker.record_failure() + + def _record_breaker_success_v2(self, endpoint: str) -> None: + if endpoint in self._NON_IDEMPOTENT_ENDPOINTS: + return + self.circuit_breaker.record_success() + async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any): """재시도 로직을 포함한 실제 요청 실행 (주문 엔드포인트는 재시도 없음)""" start_time = time.time() @@ -815,7 +888,7 @@ class KoreaInvestmentAPI: if response.status_code == 200: duration = time.time() - start_time self.monitor.record_call(success=True, duration=duration, retries=retry_count) - self.circuit_breaker.record_success() + self._record_breaker_success_v2(endpoint) if self.monitor.should_alert(threshold=settings.API_ERROR_THRESHOLD): logger.warning(f"⚠️ API 오류율 높음: {self.monitor.get_error_rate():.1%}") return response.json(), response.headers @@ -833,7 +906,7 @@ class KoreaInvestmentAPI: _payload = self._safe_json(response) if isinstance(_payload, dict) and ("rt_cd" in _payload or "msg_cd" in _payload): self.monitor.record_call(success=False, duration=time.time() - start_time, retries=retry_count) - self.circuit_breaker.record_failure() + self._record_breaker_failure_v2(endpoint) logger.warning( f"⚠️ 주문 엔드포인트 HTTP {response.status_code} — KIS 거부 응답 반환 " f"(rt_cd={_payload.get('rt_cd')} msg_cd={_payload.get('msg_cd')} " @@ -846,7 +919,7 @@ class KoreaInvestmentAPI: await asyncio.sleep(delay) continue else: - self.circuit_breaker.record_failure() + self._record_breaker_failure_v2(endpoint) raise Exception(f"API 최대 재시도 초과: {response.status_code}") # 클라이언트 오류 (4xx) — 재시도 불필요 @@ -878,7 +951,7 @@ class KoreaInvestmentAPI: else: duration = time.time() - start_time self.monitor.record_call(success=False, duration=duration, retries=retry_count) - self.circuit_breaker.record_failure() + self._record_breaker_failure_v2(endpoint) logger.error(f"❌ API 타임아웃 최대 재시도 초과") raise @@ -886,14 +959,14 @@ class KoreaInvestmentAPI: # 예상치 못한 오류 duration = time.time() - start_time self.monitor.record_call(success=False, duration=duration, retries=retry_count) - self.circuit_breaker.record_failure() + self._record_breaker_failure_v2(endpoint) logger.error(f"❌ API 요청 중 예외 발생: {str(e)}") raise # 여기까지 왔다면 모든 재시도 실패 duration = time.time() - start_time self.monitor.record_call(success=False, duration=duration, retries=retry_count) - self.circuit_breaker.record_failure() + self._record_breaker_failure_v2(endpoint) if last_exception: raise last_exception raise Exception("API 요청 실패 (알 수 없는 오류)") diff --git a/backend/app/services/kr_backtest_engine.py b/backend/app/services/kr_backtest_engine.py index e7dc0ca..899f834 100644 --- a/backend/app/services/kr_backtest_engine.py +++ b/backend/app/services/kr_backtest_engine.py @@ -31,11 +31,13 @@ from app.services.mean_reversion_strategy import ( calculate_atr, calculate_bollinger, calculate_rsi, - check_buy_signal, + check_buy_signal_backtest_v2, check_exit_signal, detect_market_regime, is_reversal_candle, + ExitSignal, ) +from app.services.sizing_core import remaining_pct_after_partial_sell logger = logging.getLogger(__name__) @@ -55,6 +57,9 @@ class BarData: volume: int stock_code: str = "" stock_name: str = "" + # True 면 DB 에 없는 날(거래정지·부분 수집일·상장폐지 후)을 전일 종가로 메운 가짜 봉. + # 체결·신호·지표 계산에서 제외한다 (기본 False = 기존 호출부와 호환). + is_filler: bool = False @dataclass @@ -113,7 +118,10 @@ class Position: entry_price: float quantity: int highest_since_entry: float # 트레일링 스탑용 최고가 - remaining_pct: float = 1.0 # 분할매도 잔여 비율 + remaining_pct: float = 1.0 # 분할매도 잔여 비율 (원본 수량 대비 단계값) + original_quantity: int = 0 # 진입 시 원본 수량 (0 이면 quantity 로 간주) + entry_bar_idx: int = -1 # 진입 체결 봉 인덱스 (시간손절 보유 거래일 계산, -1=미상) + strategy: str = "MEAN_REVERSION" # 청산 분기용 ("MOMENTUM_BREAKOUT" 이면 모멘텀 전용 청산) # --------------------------------------------------------------------------- @@ -142,6 +150,13 @@ def _apply_sell_slippage(price: float, seed: Optional[int] = None) -> float: return price * (1 - slip) +def _apply_sell_slippage_v2(price: float, seed: Optional[int] = None, + max_pct: float = SLIPPAGE_PCT) -> float: + """장중 손절 체결용 매도 슬리피지 — 폭(max_pct)을 별도 파라미터(stop_slippage_pct)로 지정.""" + rng = random.Random(seed) + return price * (1 - rng.uniform(0, abs(max_pct))) + + def _calc_buy_cost(price: float, quantity: int) -> Tuple[float, float]: """매수 비용 계산 -> (총비용, 수수료)""" amount = price * quantity @@ -330,6 +345,181 @@ class BacktestStrategy(ABC): """ ... + def intraday_exits_v2( + self, + bar_idx: int, + bars: Dict[str, List[BarData]], + positions: Dict[str, Position], + params: Dict[str, Any], + ) -> List[Dict[str, Any]]: + """장중 체결형 청산(손절). 엔진이 bar_idx 봉 시가 체결 직후 호출한다. + 반환: [{"stock_code", "base_price"(슬리피지 전 체결기준가), "reason"}] — 전량 청산. + 기본은 없음(기존 전략 호환).""" + return [] + + +def _bt_param_or_default_v2(params: Dict[str, Any], key: str, default: float, + lo: float, hi: float) -> float: + """백테스트 파라미터 검증: 유한하고 [lo, hi] 안이면 params 값, 아니면 default(+warning).""" + val = params.get(key) + if val is None: + return float(default) + try: + f = float(val) + except (TypeError, ValueError): + f = float("nan") + if f != f or f in (float("inf"), float("-inf")) or not (lo <= abs(f) <= hi): + logger.warning("[KRBacktest] 파라미터 %s=%r 비정상 — 기본값 %s 사용", key, val, default) + return float(default) + return f + + +def _stop_price_v2(params: Dict[str, Any], entry_price: float, atr: float, + ref_close: float) -> Tuple[float, float, float, float]: + """손절가 단일 계산(종가 판정·장중 판정 공용) -> (stop, atr_stop, pct_stop, stop_loss_pct). + + stop = max(진입-배수×ATR, 진입×(1+손절률)) — 가격 비교이므로 max 가 타이트한 쪽(§5.2). + 고변동(ATR/ref_close > 3%)이면 STOP_LOSS_HIGH_VOL. stop_loss_pct 는 `-abs()` 부호 가드. + """ + atr_mult = _bt_param_or_default_v2( + params, "atr_stop_multiplier", settings.ATR_STOP_MULTIPLIER, + float(settings.SWING_ATR_STOP_MULT_MIN), float(settings.SWING_ATR_STOP_MULT_MAX)) + atr_pct = (float(atr) / ref_close * 100) if (atr and ref_close > 0) else 0.0 + if atr_pct > 3.0: + sl_raw = _bt_param_or_default_v2( + params, "stop_loss_high_vol", settings.STOP_LOSS_HIGH_VOL, + float(settings.SWING_STOP_LOSS_PCT_MIN), float(settings.SWING_STOP_LOSS_PCT_MAX)) + else: + sl_raw = _bt_param_or_default_v2( + params, "stop_loss_pct", settings.STOP_LOSS_PERCENT, + float(settings.SWING_STOP_LOSS_PCT_MIN), float(settings.SWING_STOP_LOSS_PCT_MAX)) + stop_loss_pct = -abs(sl_raw) + atr_stop = entry_price - atr_mult * atr + pct_stop = entry_price * (1 + stop_loss_pct / 100) + return max(atr_stop, pct_stop), atr_stop, pct_stop, stop_loss_pct + + +def intraday_stop_fill_v2(params: Dict[str, Any], entry_price: float, atr: float, + prev_close: float, bar_open: float, bar_low: float + ) -> Optional[Tuple[float, float]]: + """장중 손절 판정(순수) -> (체결기준가, 손절가) 또는 None. + + 실전은 60초 주기 실시세로 close<=stop 을 보고 시장가 매도하므로, 일봉에서는 + bar.low <= stop 이면 손절로 본다. 체결 기준가는 min(stop, open) — 갭다운이면 시가. + ATR·고변동 판정은 전일까지의 값(prev_close)만 쓴다(당일 정보 룩어헤드 금지). + """ + if entry_price <= 0 or atr is None or atr <= 0: + return None + stop_price, _, _, _ = _stop_price_v2(params, entry_price, atr, prev_close) + if bar_low <= stop_price: + return min(stop_price, float(bar_open)), stop_price + return None + + +def check_time_stop_backtest_v2(params: Dict[str, Any], holding_bars: int, + profit_pct: float) -> Optional[ExitSignal]: + """스윙 시간손절(순수): 보유 봉수(=거래일) >= N 이고 수익률 < 최소수익이면 전량. + + 기간·최소수익은 params(swing_time_stop_*) → settings.SWING_TIME_STOP_* 순. + SWING_TIME_STOP_ENABLED 가 꺼져 있으면(params 로도 override 가능) None. + """ + enabled = params.get("swing_time_stop_enabled", settings.SWING_TIME_STOP_ENABLED) + if not enabled: + return None + max_days = int(params.get("swing_time_stop_days", settings.SWING_TIME_STOP_DAYS)) + min_profit = float(params.get("swing_time_stop_min_profit_pct", settings.SWING_TIME_STOP_MIN_PROFIT_PCT)) + if holding_bars >= max_days and profit_pct < min_profit: + return ExitSignal( + action="SELL_ALL", + reason=f"시간손절 ({holding_bars}거래일, {profit_pct:+.1f}% < +{min_profit}%)", + sell_pct=1.0, + ) + return None + + +def check_momentum_exit_backtest_v2(params: Dict[str, Any], close: float, entry_price: float, + atr: float, remaining_pct: float, + trailing_high: Optional[float]) -> Optional[ExitSignal]: + """모멘텀 돌파 전용 청산(결정론). swing/exit_service._check_momentum_exit 와 같은 구조: + 손절 → 트레일링(수익 중) → +10% 30% → +20% 30%. 라이브 적응형·DB 를 읽지 않는다.""" + if entry_price <= 0: + return None + profit_pct = (close - entry_price) / entry_price * 100 + stop_price, _, _, _ = _stop_price_v2(params, entry_price, atr, close) + if close <= stop_price: + return ExitSignal(action="SELL_ALL", + reason=f"모멘텀 손절 ({profit_pct:.1f}%, stop={stop_price:.0f})", sell_pct=1.0) + if trailing_high and trailing_high > entry_price: + trail_mult = float(params.get("atr_trailing_multiplier", settings.ATR_TRAILING_MULTIPLIER)) + trail_stop = trailing_high - trail_mult * atr + if close <= trail_stop and profit_pct > 0: + return ExitSignal(action="SELL_ALL", + reason=f"모멘텀 트레일링 (고점={trailing_high:.0f}, 트레일={trail_stop:.0f})", + sell_pct=1.0) + if profit_pct >= 10.0 and remaining_pct > 0.75: + return ExitSignal(action="SELL_PARTIAL", reason=f"모멘텀 1차 익절 +{profit_pct:.1f}% → 30% 매도", + sell_pct=0.30) + if profit_pct >= 20.0 and 0.45 < remaining_pct <= 0.75: + return ExitSignal(action="SELL_PARTIAL", reason=f"모멘텀 2차 익절 +{profit_pct:.1f}% → 30% 매도", + sell_pct=0.30) + return None + + +def check_exit_signal_backtest_v2( + params: Dict[str, Any], + rsi: float, + close: float, + bb_upper: float, + bb_middle: float, + entry_price: float, + atr: float, + remaining_pct: float, + trailing_high: Optional[float] = None, +) -> Optional[ExitSignal]: + """백테스트 전용 청산 판정 — 라이브 적응형 모드·DB(strategy_cfg)를 읽지 않는다. + + 손절·익절 임계는 전부 `params`(기본 settings) 에서 명시 주입된다. 같은 입력이면 + 실행 시각과 무관하게 같은 결과(결정론). 로직 구조는 mean_reversion_strategy.check_exit_signal + 과 동일(손절 → 1차 익절 → 전량/트레일링). 손절률은 `-abs()` 부호 가드를 적용한다. + """ + stop_price, atr_stop, pct_stop, stop_loss_pct = _stop_price_v2(params, entry_price, atr, close) + if close <= stop_price: + return ExitSignal( + action="SELL_ALL", + reason=f"손절 (close={close:.0f} <= stop={stop_price:.0f}, ATR기준={atr_stop:.0f}, {stop_loss_pct:.1f}%기준={pct_stop:.0f})", + sell_pct=1.0, + ) + + rsi_partial = float(params.get("rsi_exit_threshold", settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD)) + rsi_full = float(params.get("rsi_full_exit", settings.MEAN_REVERSION_RSI_FULL_EXIT)) + trail_mult = float(params.get("atr_trailing_multiplier", 1.5)) + threshold = 0.50 + profit_pct = ((close - entry_price) / entry_price * 100) if entry_price > 0 else 0.0 + if remaining_pct > threshold and profit_pct >= 0.5: + reasons = [] + if rsi >= rsi_partial: + reasons.append(f"RSI({rsi:.1f})>={rsi_partial:.0f}") + if close > bb_middle: + reasons.append(f"BB중앙({bb_middle:.0f})상회") + if close >= entry_price + 2.0 * atr: + reasons.append(f"2×ATR({atr:.0f})수익") + if reasons: + return ExitSignal(action="SELL_PARTIAL", + reason=f"1차 익절(50%): {' OR '.join(reasons)}", sell_pct=0.50) + + full = [] + if rsi >= rsi_full: + full.append(f"RSI({rsi:.1f})>={rsi_full}") + if close >= bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD: + full.append(f"BB상단({bb_upper:.0f})도달") + if remaining_pct <= threshold + 1e-9 and trailing_high is not None and trailing_high > entry_price: + trail_stop = trailing_high - trail_mult * atr + if close < trail_stop: + full.append(f"트레일링(close={close:.0f}<{trail_stop:.0f})") + if full: + return ExitSignal(action="SELL_ALL", reason=f"전량청산: {' OR '.join(full)}", sell_pct=1.0) + return None + # --------------------------------------------------------------------------- # 내장 전략: MeanReversionBacktest (RSI + BB 평균회귀) @@ -338,7 +528,7 @@ class BacktestStrategy(ABC): class MeanReversionBacktest(BacktestStrategy): """CLAUDE.md 섹션 6 RSI+BB 평균회귀 전략 백테스트 버전 - 기존 mean_reversion_strategy.py의 check_buy_signal / check_exit_signal을 + 기존 mean_reversion_strategy.py의 check_buy_signal_backtest_v2(결정론 진입) / check_exit_signal_backtest_v2 를 그대로 재활용합니다. 시장 국면(regime)은 코스피200 데이터가 없으므로 전체 종목 평균으로 대체하거나, 파라미터로 고정 지정 가능합니다. """ @@ -357,11 +547,12 @@ class MeanReversionBacktest(BacktestStrategy): "max_positions": settings.MAX_HOLDINGS_COUNT, "position_size_pct": settings.MAX_POSITION_SIZE, "stop_loss_pct": settings.STOP_LOSS_PERCENT, + "stop_loss_high_vol": settings.STOP_LOSS_HIGH_VOL, "atr_stop_multiplier": settings.ATR_STOP_MULTIPLIER, "atr_trailing_multiplier": settings.ATR_TRAILING_MULTIPLIER, "rsi_exit_threshold": settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD, "rsi_full_exit": settings.MEAN_REVERSION_RSI_FULL_EXIT, - "regime": "BULL", # "BULL", "BEAR", "AUTO" + "regime": "BULL", # "BULL", "BEAR", "SIDEWAYS" 고정만 지원 (AUTO 는 ValueError) "market": "KOSPI", # 세금 계산용 "min_price": settings.MIN_PRICE_KRW, "min_data_bars": 60, # RSI/BB 계산에 필요한 최소 봉 수 @@ -386,9 +577,13 @@ class MeanReversionBacktest(BacktestStrategy): continue current_bar = stock_bars[bar_idx] - closes = [b.close_price for b in stock_bars[: bar_idx + 1]] - highs = [b.high_price for b in stock_bars[: bar_idx + 1]] - lows = [b.low_price for b in stock_bars[: bar_idx + 1]] + if current_bar.is_filler: + # 결측일(정지·미수집): 손절/고점 갱신 판정 보류, 다음 실제 봉에서 판정 + continue + real_bars = _real_bars_upto(stock_bars, bar_idx) + closes = [b.close_price for b in real_bars] + highs = [b.high_price for b in real_bars] + lows = [b.low_price for b in real_bars] if len(closes) < min_bars: continue @@ -405,24 +600,44 @@ class MeanReversionBacktest(BacktestStrategy): # 최고가 업데이트 pos.highest_since_entry = max(pos.highest_since_entry, current_bar.high_price) - exit_signal = check_exit_signal( - regime=self._get_regime(params), - rsi=rsi, - close=float(current_bar.close_price), - bb_upper=bb_upper, - bb_middle=bb_middle, - bb_lower=bb_lower, - entry_price=pos.entry_price, - atr=atr, - lowest_since_entry=float(current_bar.low_price), - remaining_pct=pos.remaining_pct, - trailing_high=pos.highest_since_entry, - ) + is_momentum = str(getattr(pos, "strategy", "")).upper() == "MOMENTUM_BREAKOUT" + if is_momentum: + exit_signal = check_momentum_exit_backtest_v2( + params, float(current_bar.close_price), pos.entry_price, atr, + pos.remaining_pct, pos.highest_since_entry, + ) + else: + exit_signal = check_exit_signal_backtest_v2( + params=params, + rsi=rsi, + close=float(current_bar.close_price), + bb_upper=bb_upper, + bb_middle=bb_middle, + entry_price=pos.entry_price, + atr=atr, + remaining_pct=pos.remaining_pct, + trailing_high=pos.highest_since_entry, + ) + + # 시간손절(실전 swing/exit_service 와 같은 우선순위): 모멘텀 제외, 전량 신호가 이미 + # 있으면 유지, 없거나 부분매도면 시간손절(전량)이 덮어쓴다. 보유일=봉 인덱스 차. + if (not is_momentum and pos.entry_bar_idx >= 0 + and (exit_signal is None or exit_signal.sell_pct < 1.0)): + ts_profit = ((float(current_bar.close_price) - pos.entry_price) / pos.entry_price * 100 + if pos.entry_price > 0 else 0.0) + ts_signal = check_time_stop_backtest_v2(params, bar_idx - pos.entry_bar_idx, ts_profit) + if ts_signal is not None: + exit_signal = ts_signal if exit_signal: - sell_qty = int(pos.quantity * exit_signal.sell_pct) - if sell_qty <= 0: + # 실전 규약: sell_pct 는 원본 수량 대비, 잔량 ≤3주면 전량 + orig_qty = pos.original_quantity or pos.quantity + if exit_signal.sell_pct >= 1.0 or pos.quantity <= 3: sell_qty = pos.quantity + else: + sell_qty = min(int(orig_qty * exit_signal.sell_pct), pos.quantity) + if sell_qty <= 0: + sell_qty = pos.quantity orders.append({ "action": "SELL", @@ -447,11 +662,14 @@ class MeanReversionBacktest(BacktestStrategy): continue current_bar = stock_bars[bar_idx] - closes = [b.close_price for b in stock_bars[: bar_idx + 1]] - highs = [b.high_price for b in stock_bars[: bar_idx + 1]] - lows = [b.low_price for b in stock_bars[: bar_idx + 1]] - opens = [b.open_price for b in stock_bars[: bar_idx + 1]] - volumes = [b.volume for b in stock_bars[: bar_idx + 1]] + if current_bar.is_filler: + continue # 결측일에는 신규 진입 신호를 만들지 않는다 + real_bars = _real_bars_upto(stock_bars, bar_idx) + closes = [b.close_price for b in real_bars] + highs = [b.high_price for b in real_bars] + lows = [b.low_price for b in real_bars] + opens = [b.open_price for b in real_bars] + volumes = [b.volume for b in real_bars] if len(closes) < min_bars: continue @@ -491,7 +709,10 @@ class MeanReversionBacktest(BacktestStrategy): ma20_val = sum(closes[-20:]) / 20 if len(closes) >= 20 else None ma60_val = sum(closes[-60:]) / 60 if len(closes) >= 60 else None - signal = check_buy_signal( + # [하드닝 R2 2026-10-09] 라이브 적응형 모드·strategy_cfg 를 읽지 않는 결정론 진입 판정. + # 진입 임계(rsi_entry·bear_rsi_entry·bb_pct_b·bear_volume_mult)는 params 로 주입한다. + signal = check_buy_signal_backtest_v2( + params, regime=self._get_regime(params), rsi=rsi, close=float(current_bar.close_price), @@ -530,9 +751,56 @@ class MeanReversionBacktest(BacktestStrategy): return orders + def intraday_exits_v2( + self, + bar_idx: int, + bars: Dict[str, List[BarData]], + positions: Dict[str, Position], + params: Dict[str, Any], + ) -> List[Dict[str, Any]]: + """장중 저가 손절: bar.low <= stop 이면 min(stop, open) 기준가로 전량 청산. + + ATR 은 전일까지의 실제 봉으로만 계산(룩어헤드 없음). params['intraday_stop_enabled']=False + 로 끄면 종전(종가 판정) 동작 — A-B 비교용.""" + if not params.get("intraday_stop_enabled", True) or bar_idx < 1: + return [] + min_bars = params.get("min_data_bars", 60) + out: List[Dict[str, Any]] = [] + for code, pos in list(positions.items()): + stock_bars = bars.get(code, []) + if bar_idx >= len(stock_bars): + continue + cur = stock_bars[bar_idx] + if cur.is_filler: + continue + prev = _real_bars_upto(stock_bars, bar_idx - 1) + if len(prev) < min_bars: + continue + atr = calculate_atr([b.high_price for b in prev], [b.low_price for b in prev], + [b.close_price for b in prev], params.get("atr_period", 14)) + if atr is None: + continue + hit = intraday_stop_fill_v2(params, pos.entry_price, atr, float(prev[-1].close_price), + float(cur.open_price), float(cur.low_price)) + if hit is None: + continue + base, stop = hit + out.append({ + "stock_code": code, + "base_price": base, + "reason": f"장중손절 (low={cur.low_price} <= stop={stop:.0f}, 체결기준={base:.0f})", + }) + return out + @staticmethod def _get_regime(params: Dict[str, Any]) -> MarketRegime: - regime_str = params.get("regime", "BULL").upper() + regime_str = str(params.get("regime", "BULL")).upper() + if regime_str == "AUTO": + # 지수(코스피200/KODEX200) 데이터가 DB 에 없어 일자별 국면을 재현할 수 없다. + # 조용히 BULL 로 돌리면 SIDEWAYS/BEAR 게이트가 빠진 결과가 실전처럼 보인다. + raise ValueError("regime='AUTO' 는 미지원(지수 데이터 없음) — BULL/BEAR/SIDEWAYS 중 고정 지정") + if regime_str not in ("BULL", "BEAR", "SIDEWAYS"): + raise ValueError(f"알 수 없는 regime: {regime_str}") if regime_str == "BEAR": return MarketRegime.BEAR if regime_str == "SIDEWAYS": @@ -575,6 +843,50 @@ def get_strategy(name: str) -> BacktestStrategy: # 데이터 로더 (async — DB 조회) # --------------------------------------------------------------------------- +PRICE_EVENT_TOL = 0.25 # collect_krx_data.PRICE_EVENT_TOL 과 동일 정의 +PRICE_EVENT_MAX_GAP_DAYS = 4 # 이보다 긴 누락 구간은 이벤트가 아니라 갭 (수집기와 동일) + + +def back_adjust_price_events_v2( + bars: List[BarData], + change_rates: List[Optional[float]], + tol: float = PRICE_EVENT_TOL, + max_gap_days: int = PRICE_EVENT_MAX_GAP_DAYS, +) -> int: + """원주가 불연속을 감지해 이벤트 이전 봉의 OHLC/거래량을 제자리에서 역보정한다. 순수 함수. + + change_rates[i] 는 bars[i] 의 거래소 등락률(소수, 분할 반영된 값). 실제 종가비와 tol 초과 + 괴리면 이벤트: 이전 봉들을 k = close / (prev_close*(1+cr)) 배 하고 거래량은 k 로 나눈다. + change_rate 가 없으면 판정 불가라 보정하지 않는다. 반환: 보정한 이벤트 수. + """ + n = 0 + for i in range(len(bars) - 1, 0, -1): + cr = change_rates[i] if i < len(change_rates) else None + if cr is None: + continue + cur, prev = bars[i], bars[i - 1] + if not cur.close_price or not prev.close_price or cur.close_price <= 0 or prev.close_price <= 0: + continue + if (cur.trade_date - prev.trade_date).days > max_gap_days: + continue + if abs(cur.close_price / prev.close_price - 1.0 - cr) <= tol: + continue + denom = prev.close_price * (1.0 + cr) + if cr <= -0.99 or denom <= 0: + continue + k = cur.close_price / denom + if k <= 0: + continue + for b in bars[:i]: + b.open_price = int(round(b.open_price * k)) + b.high_price = int(round(b.high_price * k)) + b.low_price = int(round(b.low_price * k)) + b.close_price = int(round(b.close_price * k)) + b.volume = int(round(b.volume / k)) + n += 1 + return n + + async def load_daily_bars( stock_codes: List[str], start_date: date, @@ -591,6 +903,8 @@ async def load_daily_bars( from app.core.database import AsyncSessionLocal from app.models.models import StockDailyPrice + # 주의: stock_daily_prices 는 수정주가가 아니다. 분할·병합·권리락 불연속은 + # change_rate(거래소 등락률)와 종가비 괴리로 감지해 과거 봉을 역보정한다. bars_by_code: Dict[str, List[BarData]] = {} all_dates_set: set = set() @@ -613,7 +927,11 @@ async def load_daily_bars( continue bar_list: List[BarData] = [] + change_rates: List[Optional[float]] = [] for row in rows: + change_rates.append( + float(row.change_rate) if row.change_rate is not None else None + ) bar_list.append(BarData( trade_date=row.trade_date, open_price=row.open_price or 0, @@ -626,12 +944,22 @@ async def load_daily_bars( )) all_dates_set.add(row.trade_date) + n_events = back_adjust_price_events_v2(bar_list, change_rates) + if n_events: + logger.warning( + "종목 %s: 원주가 불연속 %d건 역보정 (분할/병합/권리락 추정)", code, n_events, + ) bars_by_code[code] = bar_list all_dates = sorted(all_dates_set) return bars_by_code, all_dates +def _real_bars_upto(stock_bars: List[BarData], bar_idx: int) -> List[BarData]: + """bar_idx 까지의 실제 봉만 (가짜 채움 봉 제외) — 지표 계산용""" + return [b for b in stock_bars[: bar_idx + 1] if not getattr(b, "is_filler", False)] + + def _align_bars_to_dates( bars_by_code: Dict[str, List[BarData]], all_dates: List[date], @@ -664,6 +992,7 @@ def _align_bars_to_dates( volume=0, stock_code=code, stock_name=last_bar.stock_name, + is_filler=True, ) result.append(filler) # 첫 날짜 이전 데이터가 없으면 건너뜀 (나중에 min_bars로 필터) @@ -730,6 +1059,8 @@ class KrBacktestEngine: if params: effective_params.update(params) effective_params["market"] = market + # 데이터 로드 전에 거부 (AUTO 가 조용히 BULL 로 처리되던 결함) + MeanReversionBacktest._get_regime(effective_params) # 1. 데이터 로드 logger.info( @@ -810,6 +1141,7 @@ class KrBacktestEngine: total_equity = cash + position_value # -- 1단계: 전일 신호를 오늘 open + 슬리피지로 체결 -- + carry_orders: List[Dict[str, Any]] = [] for order in pending_orders: action = order["action"] code = order["stock_code"] @@ -819,6 +1151,12 @@ class KrBacktestEngine: stock_bars = bars.get(code, []) if bar_idx >= len(stock_bars): continue + if getattr(stock_bars[bar_idx], "is_filler", False): + # 가짜 봉(정지·미수집일)에서는 체결 불가 -> 매도만 다음 실제 봉으로 이월. + # 매수는 낡은 신호이므로 폐기. 이후 실제 봉이 없으면(상장폐지 등) 미체결 유지. + if order["action"] == "SELL": + carry_orders.append(order) + continue next_open = float(stock_bars[bar_idx].open_price) # 슬리피지용 시드 @@ -861,6 +1199,8 @@ class KrBacktestEngine: entry_price=exec_price, quantity=quantity, highest_since_entry=exec_price, + original_quantity=quantity, + entry_bar_idx=bar_idx, ) elif action == "SELL": @@ -905,9 +1245,44 @@ class KrBacktestEngine: entry_trades.pop(code, None) else: pos.quantity = remaining_qty - pos.remaining_pct = remaining_qty / (pos.quantity + sell_qty) + pos.remaining_pct = remaining_pct_after_partial_sell( + pos.remaining_pct, + float(order.get("sell_pct", 0.0) or 0.0), + remaining_qty, + pos.original_quantity or (remaining_qty + sell_qty), + ) - pending_orders.clear() + pending_orders = carry_orders + + # -- 1.5단계: 장중 손절(저가 터치) — 오늘 봉 안에서 체결 (실전 60초 감시 근사) -- + for ix in strategy.intraday_exits_v2(bar_idx, bars, positions, params): + code = ix["stock_code"] + pos = positions.get(code) + if pos is None: + continue + if seed_counter is not None: + seed_counter += 1 + slip_seed = seed_counter + else: + slip_seed = None + base_price = float(ix["base_price"]) + exec_price = _apply_sell_slippage_v2( + base_price, slip_seed, float(params.get("stop_slippage_pct", SLIPPAGE_PCT))) + sell_qty = pos.quantity + proceeds, commission, tax = _calc_sell_proceeds(exec_price, sell_qty, market) + cash += proceeds + entry_cost = pos.entry_price * sell_qty * (1 + BUY_COMMISSION_RATE) + pnl = proceeds - entry_cost + pnl_pct = (pnl / entry_cost) * 100 if entry_cost > 0 else 0.0 + all_trades.append(BacktestTrade( + stock_code=code, side="SELL", entry_date=pos.entry_date, exit_date=current_date, + entry_price=pos.entry_price, exit_price=exec_price, quantity=sell_qty, + commission=commission, tax=tax, + slippage=abs(base_price - exec_price) * sell_qty, + pnl=round(pnl, 2), pnl_pct=round(pnl_pct, 4), reason=ix.get("reason", ""), + )) + del positions[code] + entry_trades.pop(code, None) # -- 2단계: 오늘 close 기준으로 신호 생성 (체결은 내일 open) -- is_last_bar = (bar_idx == len(all_dates) - 1) @@ -928,7 +1303,7 @@ class KrBacktestEngine: total_equity=total_equity, params=params, ) - pending_orders = list(orders) + pending_orders = carry_orders + list(orders) # 일말 평가 position_value = 0.0 @@ -953,6 +1328,15 @@ class KrBacktestEngine: "drawdown_pct": round(dd, 4), }) + # 종료 시점에 마지막 봉이 가짜 봉인 보유 종목: 정지/상장폐지 가능성 -> 청산 불가 상태로 평가됨 + for code in positions: + sb = bars.get(code, []) + if sb and getattr(sb[-1], "is_filler", False): + logger.warning( + "백테스트 종료 시점 %s 은 실제 시세가 끊긴 채(정지/상장폐지 추정) 마지막 실제 종가로 평가됨 — 손실이 과소평가될 수 있음", + code, + ) + # 성과 지표 계산 metrics = _compute_metrics( equity_curve=equity_values, diff --git a/backend/app/services/loss_tracker.py b/backend/app/services/loss_tracker.py index e34dd60..7148700 100644 --- a/backend/app/services/loss_tracker.py +++ b/backend/app/services/loss_tracker.py @@ -11,6 +11,13 @@ from typing import Dict, Optional logger = logging.getLogger(__name__) +# 기동 복원 재시도 간격(초) — 테스트에서 0 으로 덮어쓴다. +_LOAD_RETRY_DELAYS = (1.0, 2.0) +# 복원 실패 후 진입 경로에서의 재시도 최소 간격(초) +_LOAD_RETRY_MIN_INTERVAL_SEC = 30.0 +# 백그라운드 재복원 1회의 전체 상한(초) — 느린 DB 에서 태스크가 무한정 매달리지 않게 한다. +_LOAD_RETRY_TASK_TIMEOUT_SEC = 15.0 + # tz-aware/naive 경계 정규화 — app/core/dt_utils 참고 (db_worker.py와 공유) from app.core.dt_utils import to_naive_utc as _to_naive_utc, to_aware_utc as _to_aware_utc # noqa: E402 @@ -33,27 +40,73 @@ class ConsecutiveLossTracker: # {stock_code: {"consecutive_losses": int, "ban_until": datetime|None}} self._tracker: Dict[str, dict] = {} self._lock = asyncio.Lock() # [FIX] 동시 접근 방지용 비동기 락 + # DB 복원 실패 상태(fail-closed): True 인 동안 is_banned() 는 모든 종목을 금지로 본다. + # 기본 False — load_from_db 를 호출하지 않는 인스턴스(테스트·MT)는 영향 없음. + self._load_failed: bool = False + self._last_load_attempt: float = 0.0 + # 백그라운드 재복원 태스크(단일 비행) + self._reload_task: Optional["asyncio.Task"] = None + # 복원 실패 중 기록되어 DB 저장이 보류된 종목(복원 성공 후 병합·저장) + self._dirty: set = set() + # 복원 실패 중 종목별 결과 이벤트(순서 보존, True=손실). 복원 성공 시 DB 상태 위에 재생한다. + self._pending_events: Dict[str, list] = {} def record_trade_result(self, stock_code: str, is_loss: bool): """매매 결과 인메모리 기록 (DB 저장은 save_to_db 별도 호출) + 시그니처 불변. 구현은 record_trade_result_v2 로 위임한다(유효한 ban 보존). + Args: stock_code: 종목코드 is_loss: 손실 여부 """ + self.record_trade_result_v2(stock_code, is_loss) + + def record_trade_result_v2(self, stock_code: str, is_loss: bool): + """[v2] 매매 결과 기록 — 유효한 ban 은 이익 청산으로 해제되지 않는다. + + ban_until > now 인 동안 이익 기록은 ban_until/연패 카운터를 모두 유지한다 + (밴 이전 포지션·외부 유입 보유분의 이익 청산이 42일 금지를 풀던 결함). + ban 만료 후의 이익 기록만 카운터를 리셋한다. + """ if stock_code not in self._tracker: self._tracker[stock_code] = {"consecutive_losses": 0, "ban_until": None} - entry = self._tracker[stock_code] + if self._load_failed: + # DB 기준선 없이 0 부터 센 증분이므로 이벤트 순서를 보존해 복원 후 재생한다. + self._pending_events.setdefault(stock_code, []).append(bool(is_loss)) + self._dirty.add(stock_code) + + self._apply_result_impl(stock_code, self._tracker[stock_code], is_loss) + + def _apply_result_impl(self, stock_code: str, entry: dict, is_loss: bool): + """단일 결과를 entry 에 적용(record_trade_result_v2 본체, 복원 후 재생에도 사용).""" if is_loss: + _bu = entry.get("ban_until") + _now = datetime.now(timezone.utc) + _ban_active = _bu is not None and _now < _to_aware_utc(_bu) + if _bu is not None and not _ban_active: + # 만료된 밴: 카운터를 먼저 리셋(손실 1건으로 즉시 재밴 방지) + entry["consecutive_losses"] = 0 + entry["ban_until"] = None entry["consecutive_losses"] += 1 + if _ban_active: + # 유효 밴 중 추가 손실은 ban_until 을 연장하지 않는다 + return if entry["consecutive_losses"] >= self.ban_count: - entry["ban_until"] = datetime.now(timezone.utc) + timedelta(days=self.ban_calendar_days) + entry["ban_until"] = _now + timedelta(days=self.ban_calendar_days) logger.warning( f"{stock_code}: {entry['consecutive_losses']}회 연속 손실 → " f"{entry['ban_until'].strftime('%Y-%m-%d')}까지 진입 금지" ) else: + ban_until = entry.get("ban_until") + if ban_until is not None and datetime.now(timezone.utc) < _to_aware_utc(ban_until): + logger.info( + f"{stock_code}: 진입 금지 유효({ban_until.strftime('%Y-%m-%d')}까지) — " + f"이익 청산이지만 ban 유지" + ) + return entry["consecutive_losses"] = 0 entry["ban_until"] = None @@ -73,6 +126,14 @@ class ConsecutiveLossTracker: if entry is None: return + if self._load_failed: + # 복원 실패 상태에서 저장하면 DB 의 기존 밴 행을 덮어써 42일 밴이 소실된다. + self._dirty.add(stock_code) + logger.error( + f"loss_tracker 복원 실패 상태 — {stock_code} DB 저장 보류(복원 성공 후 병합 저장)" + ) + return + # 큐에 UPSERT 작업 등록 (db_worker가 비동기적으로 처리) # ban_until 컬럼은 tz-naive이므로 저장 직전 naive-UTC로 정규화한다. await enqueue_stock_ban_upsert( @@ -149,41 +210,139 @@ class ConsecutiveLossTracker: logger.exception(f"loss_tracker_v2 DB 로드 실패 (user={user_id})") async def load_from_db(self, db): - """앱 시작 시 DB에서 ban 상태 복원""" + """앱 시작 시 DB에서 ban 상태 복원 (시그니처 불변). + + 실패 시 짧게 재시도하고, 끝내 실패하면 _load_failed=True (fail-closed: 신규 진입 차단) + + 텔레그램 경고. 성공하면 플래그 해제. + """ + import time as _time + + last_err = None + attempts = len(_LOAD_RETRY_DELAYS) + 1 + for attempt in range(attempts): + try: + await self._load_from_db_once(db) + self._last_load_attempt = _time.monotonic() + if self._load_failed: + logger.warning("loss_tracker DB 복원 성공 — fail-closed 해제") + self._load_failed = False + self._pending_events.clear() + await self._flush_dirty() + return + except Exception as e: + last_err = e + logger.warning(f"loss_tracker DB 로드 실패 ({attempt + 1}/{attempts}): {e}") + try: + await db.rollback() + except Exception as rb: + logger.warning(f"loss_tracker 로드 실패 후 rollback 실패: {rb}") + if attempt < attempts - 1: + await asyncio.sleep(_LOAD_RETRY_DELAYS[attempt]) + + self._last_load_attempt = _time.monotonic() + first = not self._load_failed + self._load_failed = True + logger.error( + f"loss_tracker DB 로드 최종 실패 — 신규 진입 차단(fail-closed), 저장 보류: {last_err}" + ) + if first: + try: + from app.services.telegram_notifier import telegram_notifier + await telegram_notifier.notify_system_warning( + "LOSS_TRACKER", + "연속손실 밴 DB 복원 실패 - 복원될 때까지 신규 진입 차단", + ) + except Exception as ne: + logger.warning(f"loss_tracker 복원 실패 텔레그램 알림 실패: {ne}") + + async def _load_from_db_once(self, db): + """단일 시도 — 실패 시 예외를 그대로 올린다.""" + from sqlalchemy import select + from app.models.models import StockBan + + result = await db.execute(select(StockBan)) + records = result.scalars().all() + + now = datetime.now(timezone.utc) + loaded = 0 + for rec in records: + # 만료된 ban은 초기화. DB의 ban_until은 tz-naive → 비교 전 정규화. + ban_until = _to_aware_utc(rec.ban_until) + if ban_until and now >= ban_until: + ban_until = None + consecutive_losses = 0 + else: + consecutive_losses = rec.consecutive_losses + + new_entry = { + "consecutive_losses": consecutive_losses, + "ban_until": ban_until, + } + events = self._pending_events.get(rec.stock_code) + if events: + # 복원 실패 중 기록된 결과를 DB 상태 위에 순서대로 재생(밴 판정 포함) + for _is_loss in events: + self._apply_result_impl(rec.stock_code, new_entry, _is_loss) + ban_until = new_entry["ban_until"] + self._tracker[rec.stock_code] = new_entry + if ban_until: + loaded += 1 + logger.info( + f"ban 복원: {rec.stock_code} → {ban_until.strftime('%Y-%m-%d')}까지 진입 금지" + ) + + logger.info(f"loss_tracker DB 로드 완료: {len(records)}건 중 {loaded}건 활성 ban") + + async def _flush_dirty(self): + """복원 성공 후 보류됐던 저장을 큐에 등록.""" + dirty, self._dirty = self._dirty, set() + for code in dirty: + await self.save_to_db(None, code) + + async def retry_load_if_failed_v2(self) -> bool: + """[v2] 복원 실패 상태면(최소 간격 경과 시) 백그라운드 재복원을 예약한다. 복원 정상이면 True. + + 실시간 진입 경로에서 호출되므로 DB 를 기다리지 않고 즉시 반환한다. + 재복원은 단일 비행 백그라운드 태스크(SELECT 전용)가 수행하고 결과는 + _load_failed 플래그로만 반영된다. 진입 판정은 플래그만 보고 즉시 fail-closed 한다. + """ + if not self._load_failed: + return True + import time as _time + task = self._reload_task + if task is not None and not task.done(): + return False # 이미 진행 중 + if _time.monotonic() - self._last_load_attempt < _LOAD_RETRY_MIN_INTERVAL_SEC: + return False + self._last_load_attempt = _time.monotonic() try: - from sqlalchemy import select - from app.models.models import StockBan + self._reload_task = asyncio.get_running_loop().create_task(self._reload_in_background_impl()) + except RuntimeError as e: + logger.warning(f"loss_tracker 재복원 태스크 생성 실패: {e}") + return False - result = await db.execute(select(StockBan)) - records = result.scalars().all() + async def _reload_in_background_impl(self) -> None: + """백그라운드 재복원 본체 — wait_for 상한 적용, 예외는 warning.""" + try: + # DB-READ-EXCEPTION: stock_bans SELECT 전용 복원 재시도(commit 없음), 진입 경로 밖 백그라운드 + from app.core.database import AsyncSessionLocal - now = datetime.now(timezone.utc) - loaded = 0 - for rec in records: - # 만료된 ban은 초기화. DB의 ban_until은 tz-naive → 비교 전 정규화. - ban_until = _to_aware_utc(rec.ban_until) - if ban_until and now >= ban_until: - ban_until = None - consecutive_losses = 0 - else: - consecutive_losses = rec.consecutive_losses + async def _run(): + async with AsyncSessionLocal() as session: + await self.load_from_db(session) - self._tracker[rec.stock_code] = { - "consecutive_losses": consecutive_losses, - "ban_until": ban_until, - } - if ban_until: - loaded += 1 - logger.info( - f"ban 복원: {rec.stock_code} → {ban_until.strftime('%Y-%m-%d')}까지 진입 금지" - ) - - logger.info(f"loss_tracker DB 로드 완료: {len(records)}건 중 {loaded}건 활성 ban") - except Exception: - logger.exception("loss_tracker DB 로드 실패") + await asyncio.wait_for(_run(), timeout=_LOAD_RETRY_TASK_TIMEOUT_SEC) + except asyncio.TimeoutError: + logger.warning( + f"loss_tracker 복원 재시도 시간초과({_LOAD_RETRY_TASK_TIMEOUT_SEC}s) — fail-closed 유지" + ) + except Exception as e: + logger.warning(f"loss_tracker 복원 재시도 실패: {e}") def is_banned(self, stock_code: str) -> bool: - """해당 종목이 진입 금지 상태인지 확인""" + """해당 종목이 진입 금지 상태인지 확인 (DB 복원 실패 중에는 fail-closed 로 True)""" + if self._load_failed: + return True entry = self._tracker.get(stock_code) if not entry or not entry["ban_until"]: return False @@ -196,13 +355,15 @@ class ConsecutiveLossTracker: async def is_banned_safe(self, stock_code: str) -> bool: """[v2] 비동기 락 보호 is_banned — 동시 접근이 우려되는 경로에서 사용""" + if self._load_failed: + await self.retry_load_if_failed_v2() # 락 밖, SELECT 전용 async with self._lock: return self.is_banned(stock_code) async def record_trade_result_safe(self, stock_code: str, is_loss: bool): """[v2] 비동기 락 보호 record_trade_result""" async with self._lock: - self.record_trade_result(stock_code, is_loss) + self.record_trade_result_v2(stock_code, is_loss) async def get_all_bans_safe(self) -> list: """[v2] Lock 보호 하에 현재 활성 ban 종목 코드 목록 반환. @@ -228,6 +389,103 @@ class ConsecutiveLossTracker: } + +# ───────────────────────────────────────────────────────────────────────────── +# 1회성 소급 보정 (2026-10-10, 라운드 7) +# +# 10/01 시간손절 중립화는 기록 시점에만 적용돼, 그 이전에 손실로 센 시간손절이 +# stock_bans.consecutive_losses 에 남아 있다. 아래 함수들은 trade_history 를 현행 규칙 +# (시간손절 제외, 비용 차감 순손익)으로 재생해 "현재 DB 행 vs 재계산 결과" 차이표를 만든다. +# 자동 일괄 해제는 하지 않는다: plan 생성은 순수 계산(DB/네트워크 없음)이고, 적용은 +# 운영자가 승인한 종목코드만 db_task_queue(upsert_stock_ban) 경유로 쓴다. +# ───────────────────────────────────────────────────────────────────────────── + +def replay_streak_v2(events, ban_count: int, ban_calendar_days: int) -> dict: + """청산 이벤트를 시간순으로 재생해 현행 규칙의 연패/밴 상태를 계산(순수 함수). + + events: 시간순(오름차순)이어야 하는 dict 리스트. 키 + - at: datetime (체결/청산 시각) + - net: 왕복 순손익(float, 분할 익절 레그는 호출자가 합산해 전량 청산 단위로 전달) + - time_stop: bool (True 면 중립 — 건너뜀) + 규칙은 ConsecutiveLossTracker._apply_result_impl 과 동일(유효 밴 중 이익은 밴 유지, + 유효 밴 중 손실은 ban_until 비연장, 만료 후 손실은 카운터 리셋 뒤 1부터). + 반환: {"consecutive_losses": int, "ban_until": datetime|None} + """ + cnt = 0 + ban_until = None + for ev in events: + if ev.get("time_stop"): + continue + at = ev["at"] + is_loss = float(ev["net"]) < 0 + active = ban_until is not None and at < ban_until + if is_loss: + if ban_until is not None and not active: + cnt = 0 + ban_until = None + cnt += 1 + if active: + continue + if cnt >= ban_count: + ban_until = at + timedelta(days=ban_calendar_days) + else: + if active: + continue + cnt = 0 + ban_until = None + return {"consecutive_losses": cnt, "ban_until": ban_until} + + +def build_ban_correction_plan_v2(events_by_code: dict, current_rows: dict, + ban_count: int, ban_calendar_days: int) -> list: + """종목별 현재 stock_bans 행 vs 현행 규칙 재계산 결과 차이표(쓰기 없음). + + current_rows: {stock_code: {"consecutive_losses": int, "ban_until": datetime|None}} + 반환: 차이가 있는 종목만 [{stock_code, current, recomputed, ban_lifted}] (코드순). + ban_lifted=True 는 현재 밴이 재계산에서는 없는 경우 — 운영자가 개별 승인할 대상. + """ + plan = [] + for code in sorted(set(events_by_code) | set(current_rows)): + cur = current_rows.get(code) or {"consecutive_losses": 0, "ban_until": None} + new = replay_streak_v2(events_by_code.get(code, []), ban_count, ban_calendar_days) + if (cur.get("consecutive_losses", 0) == new["consecutive_losses"] + and cur.get("ban_until") == new["ban_until"]): + continue + plan.append({ + "stock_code": code, + "current": dict(cur), + "recomputed": new, + "ban_lifted": cur.get("ban_until") is not None and new["ban_until"] is None, + }) + return plan + + +async def apply_ban_correction_v2(plan: list, approved_codes) -> list: + """운영자가 승인한 종목만 db_task_queue(upsert_stock_ban) 로 보정 기록. 적용 종목 반환. + + approved_codes 가 비어 있으면 아무것도 쓰지 않는다. plan 에 없는 코드는 무시한다. + 인메모리 loss_tracker 는 건드리지 않는다(적용 후 재기동/load_from_db 로 반영). + """ + approved = {str(c) for c in (approved_codes or [])} + if not approved: + return [] + from app.services.db_worker import enqueue_stock_ban_upsert + + applied = [] + for row in plan: + code = row["stock_code"] + if code not in approved: + continue + rec = row["recomputed"] + await enqueue_stock_ban_upsert( + stock_code=code, + consecutive_losses=rec["consecutive_losses"], + ban_until=_to_naive_utc(rec["ban_until"]), + ) + applied.append(code) + return applied + + # 싱글톤 인스턴스 — settings에서 ban_count 주입 def _create_loss_tracker() -> ConsecutiveLossTracker: try: diff --git a/backend/app/services/market_analyzer.py b/backend/app/services/market_analyzer.py index d5630d5..c27520f 100644 --- a/backend/app/services/market_analyzer.py +++ b/backend/app/services/market_analyzer.py @@ -38,6 +38,20 @@ KST = pytz.timezone('Asia/Seoul') def _get_kst_now(): return datetime.now(KST).replace(tzinfo=None) +def _sideways_message_v2(current_price: float, ma200: float, by_hmm: bool) -> str: + """SIDEWAYS 응답 message — 실효 설정값과 판정 출처(rule/HMM)를 반영한다.""" + _enter = settings.MEAN_REVERSION_ADX_SIDEWAYS - 2.0 + _exit = settings.MEAN_REVERSION_ADX_SIDEWAYS + 2.0 + _basis = ( + "HMM 앙상블 오버라이드" + if by_hmm + else (f"규칙: ADX<{_enter:g}(해제>{_exit:g}) AND " + f"MA200 기울기<{settings.MEAN_REVERSION_SLOPE_THRESHOLD:g}") + ) + return (f"횡보장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, {_basis})" + " — 진입 차단") + + class RecommendationCriteria(BaseModel): use_macd: bool = False use_rsi: bool = False @@ -60,6 +74,8 @@ class StockRecommendation(BaseModel): signals: List[str] score: float price_date: str = "" + # 장중인데 마지막 봉이 오늘 봉이 아님(전일 봉 기반 신호). 매수 후보에서 제외할 것. + price_stale: bool = False volume: Optional[int] = None rsi: Optional[float] = None ma5: Optional[float] = None @@ -78,6 +94,7 @@ class StockRecommendation(BaseModel): _market_regime_cache: dict = {"result": None, "ts": 0.0} _MARKET_REGIME_CACHE_TTL = 120 +_OFFHOURS_REGIME_MAX_AGE_SEC = 72 * 3600 # 주말 포함 직전 장중 판정 재사용 한도 async def _get_hmm_ensemble_info( rule_regime_str: str, @@ -106,10 +123,50 @@ async def _get_hmm_ensemble_info( warn_throttled(logger, "market_analyzer:104", "HMM 앙상블 조회 실패 (규칙 기반 유지): %s", e) return {"hmm": None, "ensemble": None} +_REGIME_KIS_LOOKBACK_DAYS = 400 +_regime_source_state: dict = {"source": None, "bars": 0, "naver_bars": 0, "ts": 0.0, "blocked": False, "stale": False} + + +def get_regime_source_health() -> dict: + """국면 판정 데이터 소스 상태(/health 노출용 사본).""" + return dict(_regime_source_state) + + +def _is_stale_index_chart_v2(chart_newest_first) -> bool: + """장중인데 지수 일봉의 최신 봉 날짜가 오늘(KST)이 아니면 True. + + 날짜 필드가 없으면 검증 불가이므로 False(기존 동작 유지). + """ + if not chart_newest_first: + return False + latest = str(chart_newest_first[0].get("stck_bsop_date", "") or "") + if not latest: + return False + return latest != _get_kst_now().strftime("%Y%m%d") + + +def _regime_block_v2(message: str, source: str, bars: int, naver_bars: int) -> dict: + """국면 판별 불가 시 fail-closed 반환 + 원인 로그/상태 기록(차단은 캐시하지 않음).""" + logger.error("[Regime] 신규 진입 차단: %s (source=%s, bars=%d, naver_bars=%d)", + message, source, bars, naver_bars) + _regime_source_state.update(source=source, bars=bars, naver_bars=naver_bars, + ts=time.time(), blocked=True) + return {"regime": "neutral", "regime_200d": "bear", "message": message, + "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0} + + async def check_market_regime() -> dict: try: if not is_market_open(): - return {"regime": "neutral", "regime_200d": "bear", "message": "장외 시간 - 시장 레짐 판별 스킵", "position_multiplier": 0.5} + _off = {"regime": "neutral", "regime_200d": "bear", "message": "장외 시간 - 시장 레짐 판별 스킵", "position_multiplier": 0.5} + # 장외 사이클(장전/15:35 확정)이 가짜 'bear' 로 후보를 분류하지 않도록 + # 직전 장중 판정의 regime_200d 만 빌려온다(배수/게이트는 장외 값 유지). + _prev = _market_regime_cache["result"] + if (_prev and _prev.get("regime_200d") in ("bull", "bear", "sideways") + and (time.monotonic() - _market_regime_cache["ts"]) < _OFFHOURS_REGIME_MAX_AGE_SEC): + _off["regime_200d"] = _prev["regime_200d"] + _off["regime_200d_source"] = "last_intraday_cache" + return _off now_ts = time.monotonic() if _market_regime_cache["result"] is not None and (now_ts - _market_regime_cache["ts"]) < _MARKET_REGIME_CACHE_TTL: @@ -119,31 +176,66 @@ async def check_market_regime() -> dict: # naver_data: oldest-first → reversed → newest-first # KIS output2: newest-first (API 기본) chart_data = None + _src = "none" + _naver_n = 0 + _naver_stale_fallback = None try: from app.services.naver_finance import get_naver_daily_chart # MA200+기울기(5봉) 판별에 205봉 이상 필요 — 캘린더일 기준이므로 400일 요청(≈270거래일) naver_data = await get_naver_daily_chart("069500", days=400) # oldest-first - # 205봉 미만이면 MA200/SIDEWAYS 판별 불가 → KIS 폴백(240봉 페이지네이션)으로 넘김 + _naver_n = len(naver_data) if naver_data else 0 + # 205봉 미만이면 MA200/SIDEWAYS 판별 불가 → KIS 폴백으로 넘김 if naver_data and len(naver_data) >= 205: - chart_data = list(reversed(naver_data)) # → newest-first + _cand = list(reversed(naver_data)) # → newest-first + if _is_stale_index_chart_v2(_cand): + # 장중인데 오늘 봉이 없음 → 다음 소스(KIS 범위) 시도, 실패 시 최후 폴백 + _naver_stale_fallback = _cand + else: + chart_data = _cand + _src = "naver" except Exception as _swallowed: warn_throttled(logger, "market_analyzer:127", "[Regime] 네이버 KODEX200 차트 조회 실패(KIS 폴백 진행): %s", _swallowed) if chart_data is None: _api = _real_market_api or korea_investment_api - kospi_chart = await _api.get_daily_chart("069500", "D", limit=240) - if "output2" not in kospi_chart or not kospi_chart["output2"]: - # 국면 판별 불가 시 신규 진입 차단 (기존 0.5배 매매 지속 → 무국면 매매가 실손실 원인) - return {"regime": "neutral", "regime_200d": "bear", "message": "시장 지수 확인 불가 — 신규 진입 차단", - "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0} - chart_data = kospi_chart["output2"] # newest-first + # get_daily_chart 는 실측 30행 상한이라 205봉을 못 채운다 → 날짜 범위 페이지네이션 우선 + _rows = [] + try: + _end = datetime.now().strftime("%Y%m%d") + _start = (datetime.now() - timedelta(days=_REGIME_KIS_LOOKBACK_DAYS)).strftime("%Y%m%d") + _rows = await _api.get_daily_ohlcv_range("069500", _start, _end) or [] + except Exception as _e: + logger.warning("[Regime] KIS 범위 일봉 폴백 실패: %s", _e) + if len(_rows) >= 205 and (_naver_stale_fallback is None + or not _is_stale_index_chart_v2(_rows)): + chart_data = _rows # newest-first + _src = "kis_range" + elif _naver_stale_fallback is not None: + chart_data = _naver_stale_fallback + _src = "naver" + else: + kospi_chart = await _api.get_daily_chart("069500", "D", limit=240) + _out2 = (kospi_chart or {}).get("output2") if isinstance(kospi_chart, dict) else None + if not _out2: + return _regime_block_v2( + "시장 지수 확인 불가 — 신규 진입 차단", "none", 0, _naver_n) + chart_data = _out2 if len(_out2) >= len(_rows) else _rows + _src = "kis_chart" if chart_data is _out2 else "kis_range" # closes: newest-first (closes[0]=최신, closes[-1]=가장 오래된) closes = [float(item.get("stck_clpr", 0)) for item in chart_data] + _idx_stale = _is_stale_index_chart_v2(chart_data) + _regime_source_state.update(source=_src, bars=len(closes), naver_bars=_naver_n, + ts=time.time(), blocked=False, stale=_idx_stale) + if _idx_stale: + # 당일 봉이 없으면 아래 당일 급락(CRASH) 감지가 건너뛰어진다 — 조용히 넘기지 않는다. + warn_throttled( + logger, "market_analyzer:regime_stale", + "[Regime] 장중 지수 일봉이 낡음(최신 봉 %s != 오늘 %s, source=%s) — 당일 급락 감지 비활성", + chart_data[0].get("stck_bsop_date", ""), _get_kst_now().strftime("%Y%m%d"), _src) if len(closes) < 60: - return {"regime": "neutral", "regime_200d": "bear", "message": "데이터 부족 — 신규 진입 차단", - "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0} + return _regime_block_v2("데이터 부족 — 신규 진입 차단", _src, len(closes), _naver_n) def _cache_and_return(result: dict) -> dict: _market_regime_cache["result"] = result @@ -183,7 +275,10 @@ async def check_market_regime() -> dict: else: # 205봉 미만 → MA200/ADX 국면 판별 불가. 기존에는 MA20 프록시로 상승장 판정 후 # 매매를 지속했으나(실계좌 손실 원인), 판별 불가 시 신규 진입을 차단한다. - logger.warning(f"국면 판별 불가: 일봉 {len(closes)}개 < 205 — 신규 진입 차단") + logger.error( + "[Regime] 국면 판별 불가: 일봉 %d개 < 205 (source=%s, naver_bars=%d) — 신규 진입 차단", + len(closes), _src, _naver_n) + _regime_source_state.update(blocked=True) return _cache_and_return({ "regime": "neutral", "regime_200d": "bear", @@ -195,6 +290,7 @@ async def check_market_regime() -> dict: # ── HMM 앙상블 (optional) ──────────────────────────────────── # 규칙 기반 결과를 먼저 결정한 뒤, HMM 결과와 앙상블 + _sideways_by_hmm = False _rule_regime_str = "sideways" if is_sideways_200 else ("bull" if is_bull_200 else "bear") # HMM/앙상블에는 oldest-first 데이터 전달 (closes: newest-first → reversed) _closes_oldest = list(reversed(closes)) @@ -210,6 +306,7 @@ async def check_market_regime() -> dict: _rule_regime_str, _ensemble.get("hmm_regime"), _ensemble.get("confidence", 0), ) is_sideways_200 = True + _sideways_by_hmm = True # ── 당일 급락 감지 (사이드카/전쟁 등) ─────────────────────── # closes[0]이 오늘 데이터일 때만 적용 (일봉 미확정 시 전일 종가가 closes[0]이 되어 오탐 발생) @@ -243,7 +340,7 @@ async def check_market_regime() -> dict: return _cache_and_return({ "regime": "sideways", "regime_200d": "sideways", - "message": f"횡보장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, ADX<20 또는 기울기~0) — 진입 차단", + "message": _sideways_message_v2(current_price, ma200, _sideways_by_hmm), "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0, @@ -300,6 +397,44 @@ async def check_market_regime() -> dict: "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0} +_VOL_SESSION_MINUTES = 390 # 09:00~15:30 +_VOL_MIN_ELAPSED_FRACTION = 0.10 # 개장 직후(≈39분) 배율 폭주 방지 하한 + + +def _volume_ratio_v2(volumes: List[int], volume: int, price_date: str, now: Optional[datetime] = None) -> float: + """거래량비. 마지막 봉이 '오늘 장중 부분봉'이면 시간 정규화한다. + + 부분봉: volume / (직전 20봉 평균 × 경과 세션 비율). 오늘 봉은 분모에서 제외. + 그 외(마감 봉·과거 봉): 기존과 동일하게 volume / avg(volumes[-20:]). + """ + now = now or datetime.now(KST) + today = now.strftime("%Y%m%d") + minutes = (now.hour * 60 + now.minute) - 9 * 60 + intraday = ( + str(price_date) == today + and now.weekday() < 5 + and 0 <= minutes < _VOL_SESSION_MINUTES + ) + if intraday: + prior = volumes[-21:-1] + if len(prior) < 20: + return 0 + avg_prior = sum(prior) / 20 + frac = max(_VOL_MIN_ELAPSED_FRACTION, minutes / _VOL_SESSION_MINUTES) + denom = avg_prior * frac + return (volume / denom) if denom > 0 else 0 + avg = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 0 + return (volume / avg) if avg > 0 else 0 + + +def _is_stale_intraday_bar_v2(price_date: str, now: Optional[datetime] = None) -> bool: + """장중(is_market_open)인데 마지막 봉 날짜가 오늘이 아니면 True. 장전/장후는 전일 봉이 정상.""" + now = now or datetime.now(KST) + if not price_date or not is_market_open(): + return False + return str(price_date) != now.strftime("%Y%m%d") + + def _extract_chart_data( chart_data: List[dict], stock_code: str, @@ -467,6 +602,94 @@ def _trim_to_legacy_window( return trimmed if trimmed else chart_data +# market_data._CHART_SPLIT_MISMATCH_RATIO 와 같은 값(테스트로 동치 고정). 순환 import 회피용 사본. +_KRX_SPLIT_MISMATCH_RATIO = 0.20 + +_KRX_OVERRIDE_FIELDS = ("stck_clpr", "stck_oprc", "stck_hgpr", "stck_lwpr", "acml_vol") + + +def _merge_krx_over_naver_v2( + naver_rows: Optional[List[dict]], krx_rows: Optional[List[dict]], +) -> Optional[List[dict]]: + """네이버 일봉(oldest first)의 같은 날짜 봉을 KRX(stock_daily_prices) 값으로 덮어쓴다. + + - 네이버 종가가 KRX 와 어긋나(실측 82% 봉 >0.05%) 백테스트·상관과 다른 가격열로 + RSI/BB 게이트가 판정되던 문제 대응. KRX 에 없는 날짜(당일 장중 봉, DB 결측일)는 + 네이버 봉을 그대로 두어 봉 연속성을 유지한다. + - KRX 봉의 종가/시가/고가/저가가 0·결측이면 덮어쓰지 않는다. + - 입력 배열은 변경하지 않고 새 리스트를 반환한다. + """ + if not naver_rows: + return naver_rows + krx_by_date = {} + for r in krx_rows or []: + d = str(r.get("stck_bsop_date", "") or "") + try: + if d and all(float(r.get(k, 0) or 0) > 0 for k in ("stck_clpr", "stck_oprc", "stck_hgpr", "stck_lwpr")): + krx_by_date[d] = r + except (TypeError, ValueError): + continue + # 분할·병합 가드: 겹치는 날짜 종가가 20% 넘게 어긋나면(원주가 vs 수정주가) 덮어쓰면 + # 한 배열 안에 가짜 급락/급등 봉이 생기므로 병합하지 않고 네이버 값을 유지한다. + for n in naver_rows: + k = krx_by_date.get(str(n.get("stck_bsop_date", "") or "")) + if k is None: + continue + try: + nc = float(n.get("stck_clpr", 0) or 0) + kc = float(k.get("stck_clpr", 0) or 0) + except (TypeError, ValueError): + continue + if nc > 0 and kc > 0 and abs(kc / nc - 1) > _KRX_SPLIT_MISMATCH_RATIO: + logger.warning("KRX 병합 생략(분할/수정주가 불연속 추정): %s 네이버 %.0f vs KRX %.0f", + n.get("stck_bsop_date"), nc, kc) + return list(naver_rows) + merged = [] + for n in naver_rows: + k = krx_by_date.get(str(n.get("stck_bsop_date", "") or "")) + if k is None: + merged.append(n) + continue + row = dict(n) + for key in _KRX_OVERRIDE_FIELDS: + if key in k: + row[key] = k[key] + row["krx_overridden"] = True + merged.append(row) + return merged + + +async def _fetch_krx_rows_v2(stock_code: str, limit: int = 240) -> List[dict]: + """stock_daily_prices 최근 limit 행을 KIS 형식(oldest first)으로. 실패 시 [] (네이버 유지).""" + try: + from sqlalchemy import select as _sa_select + from app.core.database import AsyncSessionLocal + from app.models.models import StockDailyPrice + # DB-READ-EXCEPTION: 배치/분석용 일봉 SELECT 전용(commit·add 없음). 주문·콜백 경로 아님. + async with AsyncSessionLocal() as db: + res = await db.execute( + _sa_select(StockDailyPrice) + .where(StockDailyPrice.stock_code == stock_code) + .order_by(StockDailyPrice.trade_date.desc()) + .limit(limit) + ) + rows = res.scalars().all() + except Exception as e: + logger.warning("KRX 일봉 조회 실패 %s (네이버 값 유지): %s", stock_code, e) + return [] + return [ + { + "stck_bsop_date": r.trade_date.strftime("%Y%m%d"), + "stck_clpr": str(r.close_price or 0), + "stck_oprc": str(r.open_price or 0), + "stck_hgpr": str(r.high_price or 0), + "stck_lwpr": str(r.low_price or 0), + "acml_vol": str(r.volume or 0), + } + for r in reversed(rows) + ] + + # ─── ML 앙상블 스킵 집계 (2026-09-28) ─────────────────────────────────────── # 9/23~9/28 닷새간 ml_score 가 200/200 NULL 이었는데 아무 로그도 남지 않았다. # 원인은 `except Exception: pass` 였다. 사유별로 집계해 반드시 눈에 띄게 만든다. @@ -601,7 +824,8 @@ def _build_analysis_result_v2( adx_14_val = indicators["adx_14"] avg_vol_20 = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 0 - volume_ratio = (volume / avg_vol_20) if avg_vol_20 > 0 else 0 + # 거래량비: 장중 부분봉이면 직전 20봉 평균 × 경과 세션 비율로 정규화 (마감 봉은 기존과 동일) + volume_ratio = _volume_ratio_v2(volumes, volume, price_date) # 유동성 필터: 20일 평균 거래량 기준으로 판단 liquidity_vol = avg_vol_20 if avg_vol_20 > 0 else volume @@ -756,6 +980,7 @@ def _build_analysis_result_v2( signals=signals if signals else ["조건 분석 중"], score=final_score, price_date=price_date, + price_stale=_is_stale_intraday_bar_v2(price_date), volume=volume, rsi=rsi, ma5=ma5, @@ -819,6 +1044,30 @@ def _analyze_chart_items_v2( ) +async def _analyze_chart_items_offload_v2( + chart_data: List[dict], + stock_code: str, + stock_name: str, + criteria: "RecommendationCriteria", + ml_chart_data: Optional[List[dict]] = None, +) -> Optional[StockRecommendation]: + """_analyze_chart_items_v2 를 워커 스레드로 오프로드 (ML 추론이 이벤트 루프를 막지 않게). + + 종목당 소요시간을 기록하고 느리면 warning. + """ + t0 = time.monotonic() + try: + return await asyncio.to_thread( + _analyze_chart_items_v2, chart_data, stock_code, stock_name, criteria, ml_chart_data, + ) + finally: + elapsed = time.monotonic() - t0 + if elapsed >= 1.0: + logger.warning("[분석] %s 처리 %.2fs (ML 포함, 스레드 오프로드)", stock_code, elapsed) + else: + logger.info("[분석] %s 처리 %.3fs", stock_code, elapsed) + + async def analyze_stock(stock_code: str, stock_name: str, criteria: RecommendationCriteria) -> Optional[StockRecommendation]: """차트를 조회한 뒤 _analyze_chart_items에 위임. @@ -839,6 +1088,16 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati stock_code, days=_NAVER_ML_CHART_DAYS, ) # 룰 지표는 기존과 동일한 창으로 계산한다(ADX Wilder 는 봉수 의존). + if ml_chart_data: + # 룰/ML 지표를 KRX 가격열로 정합(백테스트·상관과 동일 계열). 모니터링 통계도 누적. + krx_rows = await _fetch_krx_rows_v2(stock_code) + if krx_rows: + try: + from app.services.naver_finance import compare_naver_with_krx_v2 + compare_naver_with_krx_v2(ml_chart_data, krx_rows) + except Exception as e: + logger.warning("네이버-KRX 대조 실패 %s: %s", stock_code, e) + ml_chart_data = _merge_krx_over_naver_v2(ml_chart_data, krx_rows) chart_data = _trim_to_legacy_window(ml_chart_data, _NAVER_LEGACY_DAYS) except Exception as e: logger.warning("네이버 일봉 조회 실패 %s: %s", stock_code, e) @@ -846,14 +1105,14 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati # 네이버 실패 또는 실시간 분석 → KIS API 폴백 if chart_data is None: _api = _real_market_api or korea_investment_api - # limit 기본값 240 — ML 최소 요구치(80봉)를 충분히 넘는다 + # 주의: get_daily_chart 는 실측 30행 상한(swing/entry_service 주석) — 80봉 미달이면 ML 스킵(§8.5-24) chart_response = await _api.get_daily_chart(stock_code, "D") if "output2" not in chart_response or not chart_response["output2"]: return None chart_data = list(reversed(chart_response["output2"])) ml_chart_data = None # KIS 경로는 동일 배열을 그대로 ML 에 사용 - return _analyze_chart_items_v2( + return await _analyze_chart_items_offload_v2( chart_data, stock_code, stock_name, criteria, ml_chart_data=ml_chart_data, ) except Exception as e: @@ -882,7 +1141,7 @@ async def analyze_stock_for_batch_db( ) try: chart_data = list(reversed(chart_data_newest_first)) # oldest first - return _analyze_chart_items(chart_data, stock_code, stock_name, criteria) + return await _analyze_chart_items_offload_v2(chart_data, stock_code, stock_name, criteria) except Exception as e: logger.error(f"Error analyzing {stock_code}: {str(e)}") return None diff --git a/backend/app/services/mdd_manager.py b/backend/app/services/mdd_manager.py index a85daf5..a05e51a 100644 --- a/backend/app/services/mdd_manager.py +++ b/backend/app/services/mdd_manager.py @@ -22,6 +22,94 @@ logger = logging.getLogger(__name__) _MDD_REDIS_KEY = "mdd:state" +_PAGE_FULL_SENTINEL = "" +# MDD 갱신 정지 판정 기본값(초). swing 사이클 상한(진입 300 + sleep 60 + 청산 180 + 잔고 60 = 600)보다 +# 커야 정상 사이클이 stale 로 오판되지 않는다. config.MDD_UPDATE_STALE_SEC 가 있으면 그것이 우선. +_MDD_UPDATE_STALE_SEC_DEFAULT = 900 + + +def _stale_sec_v2() -> int: + try: + return int(getattr(settings, "MDD_UPDATE_STALE_SEC", _MDD_UPDATE_STALE_SEC_DEFAULT) or 0) + except (TypeError, ValueError): + return _MDD_UPDATE_STALE_SEC_DEFAULT + + +def _balance_page_full_v2(holdings) -> bool: + """get_balance 첫 페이지가 가득 찼는지(연속조회 미구현 — 응답 밖 종목 누락 가능).""" + rows = int(getattr(settings, "SWING_BALANCE_FULL_PAGE_ROWS", 20) or 0) + return rows > 0 and len(holdings) >= rows + + +def _reduction_net_pnl_v2(avg_price: float, current_price: float, sell_qty: int) -> dict: + """[R4 2026-10-09] MDD 축소 매도의 비용 차감 순손익 (CLAUDE.md §5.1 순손익 기준). + + 이전엔 net_profit_loss 에 비용 전 gross 추정치를 넣고 commission/profit_loss_rate 를 비웠다. + 시장 구분 정보가 없어 거래세는 DAY_TRADE_DEFAULT_TAX_MARKET 세율(DT 와 동일 규약)을 쓴다. + 체결가(executed_price)는 NULL 이라 current_price 기준 추정치다 — 확정되면 fill_sweep 이 보정. + 반환: net_profit_loss / commission(왕복 비용) / profit_loss_rate(비용 차감 %). + """ + from app.services.trade_cost_core import net_realized_pnl + if str(getattr(settings, "DAY_TRADE_DEFAULT_TAX_MARKET", "KOSPI")).upper() in ("KOSDAQ", "KQ"): + tax_rate = float(settings.KR_SELL_TAX_RATE_KOSDAQ) + else: + tax_rate = float(settings.KR_SELL_TAX_RATE_KOSPI) + pnl = net_realized_pnl( + float(avg_price), float(current_price), int(sell_qty), + float(settings.KR_COMMISSION_RATE), tax_rate, + ) + return { + "net_profit_loss": pnl["net_profit_loss"], + "commission": pnl["total_cost"], + "profit_loss_rate": pnl["net_profit_loss_rate"], + } + + +_alert_tasks: set = set() # fire-and-forget 태스크 참조 보관(GC 방지) + + +def _alert_retries_v2() -> int: + try: + return max(0, int(getattr(settings, "MDD_ALERT_SEND_RETRIES", 3))) + except (TypeError, ValueError): + return 3 + + +def _alert_backoff_v2() -> float: + try: + return max(0.0, float(getattr(settings, "MDD_ALERT_RETRY_BACKOFF_SEC", 5.0))) + except (TypeError, ValueError): + return 5.0 + + +async def _send_mdd_alert_v2(action: str, drawdown: float, reduce_pct: float) -> bool: + """MDD 단계 전이 텔레그램 — 예외/False(전송 실패) 시 재시도, 끝내 실패하면 error 로그. + + notify_mdd_warning 이 bool 을 주면 그 값을, None(반환값 미사용 구현)이면 예외 없음=성공으로 본다. + """ + retries = _alert_retries_v2() + backoff = _alert_backoff_v2() + last_err = "send returned False" + for attempt in range(retries + 1): + try: + from app.services.telegram_notifier import telegram_notifier + res = await telegram_notifier.notify_mdd_warning( + action=action, drawdown=drawdown, reduce_pct=reduce_pct, + ) + if res is not False: + return True + last_err = "send returned False" + except Exception as e: + last_err = f"{type(e).__name__}: {e}" + if attempt < retries: + await asyncio.sleep(backoff * (attempt + 1)) + logger.error( + "[MDD] %s 텔레그램 알림 최종 실패(%d회 시도): %s — 운영자 수동 확인 필요", + action, retries + 1, last_err, + ) + return False + + class MDDManager: """포트폴리오 MDD 관리자 (싱글톤으로 사용)""" @@ -60,6 +148,30 @@ class MDDManager: # 레벨 축소가 **완료**됐을 때만 True 로 둔다 — 루프 시작 시 True 로 두면 루프 중 # save_to_redis 가 그 값을 영속해, 중간 재기동 시 남은 종목 축소가 영구 생략됐다. self._reduction_running: set = set() + # [R1 2026-10-09] 마지막 관측 잔고 / reset 후 peak 재설정 대기 / 갱신 신선도(staleness) + self._last_balance: float = 0.0 + self._rebase_peak_pending: bool = False + self._update_last_ok_at = None # datetime(KST). None 이면 생성 시각을 기준으로 삼는다 + self._created_at = self._now_kst_v2() + + @staticmethod + def _spawn_mdd_alert_v2(action: str, drawdown: float, reduce_pct: float) -> None: + """알림 태스크를 만들고 참조를 보관한다(GC 소실 방지). 실행 중 루프가 없으면 error.""" + try: + task = asyncio.get_running_loop().create_task( + _send_mdd_alert_v2(action, drawdown, reduce_pct) + ) + except RuntimeError as e: + logger.error("[MDD] %s 텔레그램 알림 예약 실패(이벤트 루프 없음): %s", action, e) + return + _alert_tasks.add(task) + task.add_done_callback(_alert_tasks.discard) + + @staticmethod + def _now_kst_v2(): + from datetime import datetime + from app.core.market_schedule import KST + return datetime.now(KST) def _state_snapshot_v2(self) -> dict: """[L-6 5회차] 영속 상태 스냅샷 (동기, I/O 없음 — Lock 안에서 호출 가능).""" @@ -123,12 +235,18 @@ class MDDManager: # (Redis 타임아웃 동안 can_open_new_position()·축소 루프가 락을 기다리던 문제) async with self._lock: result = self._update_locked_v2(current_balance) + if current_balance > 0: + self._last_balance = float(current_balance) snap = self._state_snapshot_v2() await self._write_state_v2(_MDD_REDIS_KEY, snap) return result def _update_locked_v2(self, current_balance: float) -> Dict: """update() 의 상태 전이 본체 (Lock 보유 중 호출, I/O 없음).""" + if self._rebase_peak_pending and current_balance > 0: + # [R1] reset() 시점에 잔고를 몰랐던 경우 — 첫 갱신 잔고를 새 기준 peak 로 삼는다 + self.peak_balance = float(current_balance) + self._rebase_peak_pending = False if current_balance > self.peak_balance: self.peak_balance = current_balance self.is_paused = False @@ -150,13 +268,7 @@ class MDDManager: self.reduction_applied = "20pct" logger.warning(f"MDD {drawdown_pct:.1f}% - 50% 강제 축소 + 신규진입 중단") # Telegram MDD 경고 - try: - from app.services.telegram_notifier import telegram_notifier - asyncio.ensure_future(telegram_notifier.notify_mdd_warning( - action="FORCE_REDUCE", drawdown=drawdown_pct, reduce_pct=0.50, - )) - except Exception as _swallowed: - logger.warning("[MDD] FORCE_REDUCE 텔레그램 알림 실패: %s", _swallowed) + self._spawn_mdd_alert_v2("FORCE_REDUCE", drawdown_pct, 0.50) return { "action": "FORCE_REDUCE", "drawdown": drawdown_pct, @@ -171,13 +283,7 @@ class MDDManager: self.reduction_applied = "10pct" logger.warning(f"MDD {drawdown_pct:.1f}% - 30% 축소 + 신규진입 중단 + 단기전략 중단") # Telegram MDD 경고 - try: - from app.services.telegram_notifier import telegram_notifier - asyncio.ensure_future(telegram_notifier.notify_mdd_warning( - action="REDUCE_AND_PAUSE", drawdown=drawdown_pct, reduce_pct=0.30, - )) - except Exception as _swallowed: - logger.warning("[MDD] REDUCE_AND_PAUSE 텔레그램 알림 실패: %s", _swallowed) + self._spawn_mdd_alert_v2("REDUCE_AND_PAUSE", drawdown_pct, 0.30) return { "action": "REDUCE_AND_PAUSE", "drawdown": drawdown_pct, @@ -264,6 +370,27 @@ class MDDManager: self._update_empty_account = bool(empty_account) self._blind_block_active = False self._blind_block_hits = 0 + self._update_last_ok_at = self._now_kst_v2() + + def _is_update_stale_v2(self) -> bool: + """[R1] 장중 마지막 갱신 성공 후 MDD_UPDATE_STALE_SEC 초과 여부 (Lock 보유 중 호출, I/O 없음). + + 예외 없이 swing 사이클이 지연·정지하면 실패 카운터가 늘지 않아 낡은 drawdown 으로 + 진입이 허용되던 구멍을 막는다. 기준 시각은 max(마지막 성공, 오늘 장 시작 09:00) — + 야간/휴장 후 첫 사이클 전에 DT 진입이 막히지 않게 한다. 장외에는 판정하지 않는다. + """ + stale_sec = _stale_sec_v2() + if stale_sec <= 0: + return False + from app.core.market_schedule import is_market_open + if not is_market_open(): + return False + now = self._now_kst_v2() + ref = self._update_last_ok_at or self._created_at + open_dt = now.replace(hour=9, minute=0, second=0, microsecond=0) + if open_dt > ref: + ref = open_dt + return (now - ref).total_seconds() > stale_sec def _is_update_blind(self) -> bool: """MDD 갱신이 '눈이 먼' 상태인지 판정 (Lock 보유 중 호출, DB/IO 없음). @@ -274,10 +401,13 @@ class MDDManager: """ if not settings.MDD_BLIND_BLOCK_ENTRY: return False - if self._update_empty_account: - return False - threshold = max(1, int(settings.MDD_BLIND_FAILURE_THRESHOLD)) - return self._update_failures >= threshold + # 빈 계좌는 '실패 카운터' 조건만 면제한다. 정체(stale) 판정은 그대로 수행해 + # 빈 계좌 응답 이후 swing 사이클이 멈춘 경우에도 fail-safe 가 작동한다. + if not self._update_empty_account: + threshold = max(1, int(settings.MDD_BLIND_FAILURE_THRESHOLD)) + if self._update_failures >= threshold: + return True + return self._is_update_stale_v2() def get_update_health(self) -> dict: """MDD 갱신 헬스 스냅샷 (동기·읽기 전용). 차단 여부 관측용.""" @@ -307,7 +437,8 @@ class MDDManager: logger.error( f"[MDD] 🚨 신규 진입 차단(fail-safe): MDD 갱신 연속 실패 " f"{self._update_failures}회 (임계 {settings.MDD_BLIND_FAILURE_THRESHOLD}) — " - f"peak/drawdown 미갱신으로 -10%/-20% 게이트가 작동하지 않음. " + f"peak/drawdown 미갱신으로 -10%/-20% 게이트가 작동하지 않음(또는 장중 갱신 정지 " + f"{_stale_sec_v2()}초 초과). " f"마지막 오류: {self._update_last_error} " f"(누적 차단 {self._blind_block_hits}회, 청산은 정상 동작)" ) @@ -322,14 +453,17 @@ class MDDManager: async def reset(self): """MDD 상태 리셋 (수동 복구 시) — async + Lock으로 동시성 안전 보장 - [2026-09-27 fail-safe 해제 누락 수정] MDD 갱신 연속 실패로 인한 - fail-safe 차단(can_open_new_position() 이 False 를 반환하는 상태)은 - 기존에는 여기서 초기화되지 않아, 운영자가 reset()을 호출해도 KIS 장애가 - 스스로 해소될 때까지 신규 진입이 계속 막혀 있었다. 호출부(관리자용 - 수동 초기화 엔드포인트, "신규 진입 차단 해제"가 목적)의 의도에 맞춰 - fail-safe 카운터/플래그도 함께 초기화한다. peak_balance/drawdown 관련 - 상태는 기존 그대로 건드리지 않는다. + [R1 2026-10-09] 플래그만 지우고 peak 를 유지하면 낙폭이 남아 있는 한 다음 + update() 가 REDUCE_AND_PAUSE 를 즉시 재발동해 전 종목 30% 를 또 매도했다. + 따라서 수동 리셋은 **peak 를 마지막 관측 잔고로 재설정**(re-base)한다 — 이후 다시 + -10% 가 되어야 재발동한다. 마지막 잔고를 모르면(재기동 직후) 다음 갱신 잔고로 + 재설정한다. fail-safe 차단 카운터/플래그도 함께 초기화한다. + fail-safe 만 풀려면 reset_fail_safe_v2() 를 쓴다(MDD 상태 불변). """ + await self.reset_v2() + + async def reset_v2(self, rebase_peak: bool = True) -> dict: + """reset() 본체. rebase_peak=False 면 구 동작(peak 유지 — 축소 재발동 가능).""" async with self._lock: self.is_paused = False self.is_short_term_strategy_paused = False @@ -337,17 +471,43 @@ class MDDManager: self._reduction_executed_10pct = False self._reduction_executed_20pct = False self._clear_reduction_progress() + old_peak = self.peak_balance + if rebase_peak: + if self._last_balance > 0: + self.peak_balance = self._last_balance + self._rebase_peak_pending = False + else: + self._rebase_peak_pending = True was_blind = self._blind_block_active or self._update_failures > 0 self._update_failures = 0 self._update_last_error = None self._update_empty_account = False self._blind_block_active = False self._blind_block_hits = 0 - logger.info("MDD 관리자 리셋") + self._update_last_ok_at = self._now_kst_v2() + logger.warning( + f"MDD 관리자 리셋 — peak {old_peak:,.0f} -> {self.peak_balance:,.0f} " + f"(rebase={rebase_peak}, pending={self._rebase_peak_pending})" + ) if was_blind: logger.info("[MDD] fail-safe 차단 해제 — 갱신 실패 카운터/차단 플래그 초기화됨") + result = { + "old_peak": old_peak, "peak_balance": self.peak_balance, + "rebase_peak": bool(rebase_peak), "rebase_pending": self._rebase_peak_pending, + } snap = self._state_snapshot_v2() await self._write_state_v2(_MDD_REDIS_KEY, snap) # [L-6] 락 밖에서 + return result + + async def reset_fail_safe_v2(self) -> None: + """fail-safe(갱신 실패/정지) 차단만 해제. peak/pause/축소 상태는 건드리지 않는다.""" + async with self._lock: + self._update_failures = 0 + self._update_last_error = None + self._update_empty_account = False + self._blind_block_active = False + self._blind_block_hits = 0 + self._update_last_ok_at = self._now_kst_v2() # ── [C-1 2026-10-02] 강제축소 진행 상태 헬퍼 (기존 시그니처 불변, 신규 메서드만) ── @@ -712,6 +872,14 @@ class MDDManager: results = [] skipped = 0 pending: list[str] = [] # 다음 주기 재시도 대상 (실패·보류) + if _balance_page_full_v2(holdings): + # [R2 LOW] 첫 페이지만 조회 — 응답 밖 종목은 축소 대상에서 빠진다. 보이는 종목은 + # 축소하되 executed 를 세우지 않아 완료로 오인되지 않게 한다. + logger.error( + f"[MDD] {action} 잔고 응답이 페이지 한도({len(holdings)}행)에 도달 — 응답 밖 보유 종목은 " + f"축소되지 않았을 수 있음(수동 확인 필요, get_balance 연속조회 미구현)" + ) + pending.append(_PAGE_FULL_SENTINEL) for h in holdings: # [M-1 4회차] 루프 도중 reset()/peak 갱신이면 남은 종목 매도를 중단한다. if self._reduction_epoch != epoch: @@ -768,8 +936,7 @@ class MDDManager: await self.save_to_redis() order_id = order_resp.get("output", {}).get("ODNO", "") avg_price = float(h.get("pchs_avg_pric", current_price)) - profit_loss = (current_price - avg_price) * sell_qty - profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0 + _pnl = _reduction_net_pnl_v2(avg_price, current_price, sell_qty) # MDD 강제매도는 시스템적(systemic) 리스크 축소이며, # 개별 종목의 전략 실패가 아니므로 loss_tracker에 기록하지 않음. @@ -781,13 +948,17 @@ class MDDManager: "order_type": "SELL", "order_price": current_price, "order_quantity": sell_qty, - "executed_price": current_price, + # [2026-10-08] 시장가 — 체결가는 swing/fill_sweep 체결조회가 채운다(그 전엔 NULL). + # total_amount·net_profit_loss 는 현재가 기준 추정치. + "executed_price": None, "executed_quantity": sell_qty, "total_amount": current_price * sell_qty, "status": "COMPLETED", "order_id": order_id, "exit_reason": f"MDD {action} ({reduce_pct*100:.0f}% 축소)", - "net_profit_loss": profit_loss, + "net_profit_loss": _pnl["net_profit_loss"], + "commission": _pnl["commission"], + "profit_loss_rate": _pnl["profit_loss_rate"], "strategy_type": "mdd_reduction", }) @@ -942,6 +1113,12 @@ class MDDManager: results = [] skipped = 0 pending: list[str] = [] # 다음 주기 재시도 대상 (실패·보류) + if _balance_page_full_v2(holdings): + logger.error( + f"[MDD v2] user={user_id} {action} 잔고 응답이 페이지 한도({len(holdings)}행)에 도달 — " + f"응답 밖 보유 종목은 축소되지 않았을 수 있음(수동 확인 필요)" + ) + pending.append(_PAGE_FULL_SENTINEL) for h in holdings: if self._reduction_epoch != epoch: logger.warning(f"[MDD v2] user={user_id} {action} 진행 중 MDD 상태 리셋 감지 — 남은 종목 축소 중단") @@ -1013,8 +1190,7 @@ class MDDManager: await self.save_to_redis_v2(user_id) order_id = order_resp.get("output", {}).get("ODNO", "") avg_price = float(h.get("pchs_avg_pric", current_price)) - profit_loss = (current_price - avg_price) * sell_qty - profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0 + _pnl = _reduction_net_pnl_v2(avg_price, current_price, sell_qty) # MDD 강제매도는 시스템적 리스크 축소 — loss_tracker 미기록 (execute_reduction과 동일 정책) await enqueue_trade_history({ @@ -1024,13 +1200,17 @@ class MDDManager: "order_type": "SELL", "order_price": current_price, "order_quantity": sell_qty, - "executed_price": current_price, + # [2026-10-08] 시장가 — 체결가는 swing/fill_sweep 체결조회가 채운다(그 전엔 NULL). + # total_amount·net_profit_loss 는 현재가 기준 추정치. + "executed_price": None, "executed_quantity": sell_qty, "total_amount": current_price * sell_qty, "status": "COMPLETED", "order_id": order_id, "exit_reason": f"MDD {action} ({reduce_pct*100:.0f}% 축소)", - "net_profit_loss": profit_loss, + "net_profit_loss": _pnl["net_profit_loss"], + "commission": _pnl["commission"], + "profit_loss_rate": _pnl["profit_loss_rate"], "strategy_type": "mdd_reduction", }) @@ -1230,7 +1410,10 @@ def _dt_position_qty(stock_code: str) -> int: from app.services.daytrading.position_manager import day_trading_state pos = day_trading_state.positions.get(stock_code) return int((pos or {}).get("quantity", 0) or 0) - except Exception: + except Exception as e: + # 조회 실패를 0 으로 삼키되 반드시 기록한다. 호출부는 0 이어도 전략명 없는 + # close/upsert 를 하지 않고 비-DT 필터를 쓰므로 DT 행 오폐기는 일어나지 않는다. + logger.warning(f"[MDD] DT 인메모리 수량 조회 실패 ({stock_code}) — 0 으로 간주: {e}") return 0 @@ -1262,19 +1445,11 @@ async def _sync_active_trade_after_reduction( """ from app.services.db_worker import ( enqueue_active_trade_upsert, - enqueue_close_active_trade, - enqueue_close_active_trade_v2, enqueue_db_write, ) dt_qty = _dt_position_qty(stock_code) - async def _close(strategy_name: Optional[str]) -> None: - if user_id is not None: - await enqueue_close_active_trade_v2(user_id, stock_code, strategy_name=strategy_name) - else: - await enqueue_close_active_trade(stock_code, strategy_name=strategy_name) - async def _close_filtered(**filters) -> None: """전략명 확장 필터(strategy_names / strategy_name_not)로 close 태스크 큐잉. @@ -1286,24 +1461,11 @@ async def _sync_active_trade_after_reduction( data["user_id"] = user_id await enqueue_db_write({"type": "close_active_trade", "data": data}) - async def _upsert(strategy_name: Optional[str], ratio: float, qty: int) -> None: - payload: dict = { - "stock_code": stock_code, - "remaining_pct_multiply": ratio, - # 잔량 갱신(미갱신 시 다음 청산이 축소 전 수량으로 과다매도) - "quantity": qty, - } - if strategy_name: - payload["strategy_name"] = strategy_name - if user_id is not None: - payload["user_id"] = user_id - await enqueue_active_trade_upsert(payload) - async def _upsert_non_dt(ratio: float, qty: int) -> None: """비-DT(스윙 계열) 최신 active 행 1건만 잔량 갱신. DT 행은 절대 건드리지 않음.""" payload: dict = { "stock_code": stock_code, - "remaining_pct_multiply": ratio, + # [하드닝 R3] remaining_pct 불변 (익절 단계 보존) — quantity 만 갱신 "quantity": qty, "strategy_name_not": DT_STRATEGY_NAMES, } @@ -1320,10 +1482,12 @@ async def _sync_active_trade_after_reduction( f"[MDD] {stock_code}: DT {dt_qty}주 병존 — 비-DT/DT ActiveTrade 각각 종료" ) else: - await _close(None) + # [R5] 전략명 없는 close 는 '최근 active 행'을 닫아 고아 DT 행을 오폐기한다. + # MDD 축소 대상은 스윙 계열이므로 비-DT 필터(NULL 포함)로 한정한다. + await _close_filtered(strategy_name_not=DT_STRATEGY_NAMES) return - # 부분 축소: 남은 비율을 기존 remaining_pct 에 곱한다 (예: 기존 0.5 × 0.7 = 0.35) + # 부분 축소: quantity 만 갱신하고 remaining_pct(익절 단계)는 유지한다 [하드닝 R3] sell_ratio = (remaining_qty / quantity) if quantity > 0 else 1.0 if dt_qty > 0: # 스윙 행의 잔량 = 전체 잔량 − DT 잔량 (DT 는 체결분만큼 인메모리에서 차감됨) @@ -1340,7 +1504,7 @@ async def _sync_active_trade_after_reduction( else: _swing_ratio = 1.0 if _swing_left <= 0: - # [M1-guard 2026-09-27] _close() 는 '관리 대상에서 제거'라는 과도한 부작용이 + # [M1-guard 2026-09-27] _close_filtered() 는 '관리 대상에서 제거'라는 과도한 부작용이 # 있으므로 인메모리 DT 수량을 신뢰할 수 있을 때만 허용한다. # quantity 는 KIS 잔고(hldg_qty), dt_qty 는 인메모리 → dt_qty > quantity 면 # 인메모리가 잔고보다 큼 = stale 확정(외부 경로로 DT 분이 이미 매도된 경우). @@ -1370,7 +1534,8 @@ async def _sync_active_trade_after_reduction( f"DT 잔량={_dt_left}, DT 행은 DT 청산 경로에 위임)" ) else: - await _upsert(None, sell_ratio, remaining_qty) + # [R5] 전략명 없는 upsert 금지 — DT 행이 덮어써지고 스윙 행이 유령화된다. + await _upsert_non_dt(sell_ratio, remaining_qty) async def _reflect_reduction( diff --git a/backend/app/services/mdd_state_backup.py b/backend/app/services/mdd_state_backup.py new file mode 100644 index 0000000..1046d4c --- /dev/null +++ b/backend/app/services/mdd_state_backup.py @@ -0,0 +1,192 @@ +"""MDD 상태의 Redis 외 보조 영속화 (파일, 2026-10-09 하드닝 R1). + +mdd:state 가 Redis 에만 있으면 Redis 초기화/볼륨 교체/연결 실패 때 peak 가 현재 잔고로 +재시드되어 낙폭 이력이 소실된다. 이 모듈은 같은 스냅샷을 `~/.cache/auto-stock/` (compose 의 +./token_cache 마운트로 유지)에 원자적으로 기록하고, Redis 복원 실패 시 폴백으로 읽는다. +DB 가 아니라 파일이므로 db_worker 규칙(§3)과 무관하다. +""" +import asyncio +import json +import logging +import os +import tempfile +import time +from pathlib import Path +from typing import Optional + +from app.core.json_helpers import _dumps + +logger = logging.getLogger(__name__) + +MDD_BACKUP_INTERVAL_SEC = 60 + + +def _default_path() -> Path: + return Path.home() / ".cache" / "auto-stock" / "mdd_state.json" + + +def _write_sync(path: Path, payload: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".mdd_state_") + try: + with os.fdopen(fd, "w") as f: + f.write(payload) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +async def save_mdd_backup_v2(mgr, path: Optional[Path] = None) -> bool: + """현재 MDD 스냅샷을 파일에 기록. peak<=0(미시드)이면 기존 백업을 덮지 않는다.""" + path = path or _default_path() + async with mgr._lock: + snap = mgr._state_snapshot_v2() + if float(snap.get("peak_balance", 0) or 0) <= 0: + return False + try: + snap["saved_at"] = time.time() # epoch (참고용 — Redis 스냅샷엔 없어 비교는 peak/플래그 기준) + await asyncio.to_thread(_write_sync, path, _dumps(snap)) + return True + except Exception as e: + logger.warning("[MDD] 파일 백업 저장 실패 (%s): %s", path, e) + return False + + +async def restore_mdd_from_backup_v2(mgr, path: Optional[Path] = None) -> bool: + """파일 백업에서 MDD 상태 복원. 성공 시 True.""" + path = path or _default_path() + try: + raw = await asyncio.to_thread(lambda: path.read_text() if path.exists() else "") + if not raw: + return False + state = json.loads(raw) + peak = float(state.get("peak_balance", 0.0)) + except Exception as e: + logger.warning("[MDD] 파일 백업 복원 실패 (%s): %s", path, e) + return False + if peak <= 0: + return False + async with mgr._lock: + mgr.peak_balance = peak + mgr.is_paused = bool(state.get("is_paused", False)) + mgr.is_short_term_strategy_paused = bool(state.get("is_short_term_strategy_paused", False)) + mgr.reduction_applied = state.get("reduction_applied") + mgr._reduction_executed_10pct = bool(state.get("_reduction_executed_10pct", False)) + mgr._reduction_executed_20pct = bool(state.get("_reduction_executed_20pct", False)) + mgr._restore_reduction_progress(state) + return True + + +async def _read_redis_saved_at_v2() -> Optional[float]: + """Redis mdd:state 의 saved_at(epoch). 없거나 읽기 실패면 None (구 스냅샷/미기록).""" + try: + from app.core.redis import get_redis + from app.services.mdd_manager import _MDD_REDIS_KEY + r = get_redis() + if not r: + return None + raw = await r.get(_MDD_REDIS_KEY) + if not raw: + return None + v = json.loads(raw).get("saved_at") + return float(v) if v is not None else None + except Exception as e: + logger.warning("[MDD] Redis saved_at 읽기 실패: %s", e) + return None + + +async def save_mdd_state_everywhere_v2(mgr, path: Optional[Path] = None) -> bool: + """Redis 와 파일에 **같은 saved_at** 을 찍어 기록. reset/peak 보정 직후(락 밖) 호출용. + + peak 가 낮아지는 변경(reset 재기준, 입출금 보정)은 peak 크기 비교로는 최신성을 알 수 없으므로 + saved_at 으로 비교한다. peak<=0 이면 아무것도 쓰지 않는다. + """ + path = path or _default_path() + async with mgr._lock: + snap = mgr._state_snapshot_v2() + if float(snap.get("peak_balance", 0) or 0) <= 0: + return False + snap["saved_at"] = time.time() + try: + from app.services.mdd_manager import _MDD_REDIS_KEY + await mgr._write_state_v2(_MDD_REDIS_KEY, snap) + except Exception as e: + logger.warning("[MDD] Redis 동기 기록 실패: %s", e) + try: + await asyncio.to_thread(_write_sync, path, _dumps(snap)) + return True + except Exception as e: + logger.warning("[MDD] 파일 백업 저장 실패 (%s): %s", path, e) + return False + + +def _progress_score_v2(state: dict) -> int: + """보수성 점수: 켜진 방어 플래그 수 (많을수록 더 진행된/보수적 상태).""" + return sum(1 for k in ("is_paused", "is_short_term_strategy_paused", + "_reduction_executed_10pct", "_reduction_executed_20pct") + if state.get(k)) + (1 if state.get("reduction_applied") else 0) + + +async def _read_file_state_v2(path: Path) -> Optional[dict]: + try: + raw = await asyncio.to_thread(lambda: path.read_text() if path.exists() else "") + if not raw: + return None + state = json.loads(raw) + if float(state.get("peak_balance", 0.0)) <= 0: + return None + return state + except Exception as e: + logger.warning("[MDD] 파일 백업 비교 읽기 실패 (%s): %s", path, e) + return None + + +async def restore_mdd_state_v2(mgr, path: Optional[Path] = None) -> str: + """Redis 와 파일 중 더 최신/보수적인 쪽으로 복원. 반환: 'redis' | 'file' | 'none'. + + Redis 는 RDB 전용이라 크래시 후 오래된 스냅샷으로 되살아날 수 있다(스냅샷에 시각 없음). + 양쪽에 saved_at 이 있으면 최신성(saved_at)으로 비교한다(리셋/출금 보정으로 peak 가 낮아져도 + 올바름). 시각이 없는 구 스냅샷끼리는 파일 peak 가 더 크면 파일이 최신이라고 본다. peak 동률이면 + 방어 플래그가 더 많이 켜진 쪽(보수적)을 쓴다. 'none' 은 호출부가 경보. + """ + path = path or _default_path() + if await mgr.load_from_redis(): + redis_state = mgr._state_snapshot_v2() + file_state = await _read_file_state_v2(path) + if file_state is not None: + f_peak = float(file_state.get("peak_balance", 0.0)) + r_peak = float(redis_state.get("peak_balance", 0.0)) + f_at = file_state.get("saved_at") + r_at = await _read_redis_saved_at_v2() + if f_at is not None and r_at is not None: + # 최신성 기준: peak 가 리셋/보정으로 낮아졌어도 더 늦게 저장된 쪽이 이긴다. + # 동일 시각이면 보수적(방어 플래그 많은) 쪽. + use_file = float(f_at) > float(r_at) or ( + float(f_at) == float(r_at) + and _progress_score_v2(file_state) > _progress_score_v2(redis_state)) + else: + # 한쪽이라도 시각이 없으면(구 스냅샷/mdd_manager 직접 기록) 기존 휴리스틱 + use_file = f_peak > r_peak or ( + f_peak == r_peak and _progress_score_v2(file_state) > _progress_score_v2(redis_state)) + if use_file: + if await restore_mdd_from_backup_v2(mgr, path): + logger.warning("[MDD] Redis 스냅샷이 파일 백업보다 오래됨(peak %s < %s) — 파일 우선", + f"{r_peak:,.0f}", f"{f_peak:,.0f}") + return "file" + return "redis" + if await restore_mdd_from_backup_v2(mgr, path): + return "file" + return "none" + + +async def mdd_backup_loop_v2(mgr, interval: float = MDD_BACKUP_INTERVAL_SEC, path: Optional[Path] = None) -> None: + while True: + await asyncio.sleep(interval) + try: + await save_mdd_backup_v2(mgr, path) + except Exception as e: + logger.warning("[MDD] 파일 백업 루프 오류: %s", e) diff --git a/backend/app/services/mean_reversion_strategy.py b/backend/app/services/mean_reversion_strategy.py index 1250823..02983e4 100644 --- a/backend/app/services/mean_reversion_strategy.py +++ b/backend/app/services/mean_reversion_strategy.py @@ -30,7 +30,7 @@ RSI + 볼린저 밴드 평균회귀 전략 (CLAUDE.md 섹션 7.1-7.5) MEAN_REVERSION_MULTIPLIER_WEAK """ import logging -from typing import Optional, Dict, List, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple from dataclasses import dataclass from enum import Enum @@ -302,6 +302,13 @@ def check_bear_buy_signal_v2( 가중 점수 합산으로 완화한다. `check_buy_signal`의 BEAR 분기에서 `MEAN_REVERSION_BEAR_V2_ENABLED=True` 일 때만 호출된다(기본 False). + ⚠️ **백테스트 전용 (2026-10-09 하드닝 R2 확인)**: `check_buy_signal` 의 호출부는 + `kr_backtest_engine`·`run_backtest` 뿐이다. 실전 1차 진입 게이트는 + `api/endpoints/recommendations.is_mean_reversion_candidate` 이고 그 BEAR 분기는 + (RSI ≤ 임계 + %B 동적 상한)만 보며 이 함수·거래량·반전캔들을 참조하지 않는다. + 따라서 이 플래그를 True 로 바꿔도 **실전 BEAR 진입은 달라지지 않는다.** + 실전 반영은 StockAnalysis 에 거래량비·반전캔들 입력을 갖춘 뒤 별도 `_v2` 배선이 필요하다. + ⚠️ 백테스트 미검증 — `True` 전환 금지. DB(NAS) 중단으로 백테스트를 돌릴 수 없고, 기존 백테스트 산출물은 실전과 다른 지표 (SMA RSI vs Wilder, 발산한 ADX)로 생성된 것이 확인되어 근거로 쓸 수 없다. @@ -340,9 +347,48 @@ def check_bear_buy_signal_v2( _t = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD") bear_rsi_threshold = float(_t) if _t is not None else settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD + return _check_bear_buy_signal_v2_impl( + rsi=rsi, + close=close, + bb_upper=bb_upper, + bb_lower=bb_lower, + avg_volume_20d=avg_volume_20d, + current_volume=current_volume, + is_reversal=is_reversal, + rsi_prev=rsi_prev, + bear_rsi_threshold=float(bear_rsi_threshold), + cfg_get=_bear_v2_cfg, + bear_vol_mult_fn=_live_bear_volume_mult_v2, + ) + + +def _live_bear_volume_mult_v2() -> float: + """BEAR 거래량 배수 — strategy_cfg(DB) > settings(config.py).""" + from app.core.config import settings + _bvm = strategy_cfg.get("MEAN_REVERSION_BEAR_VOLUME_MULT") + return float(_bvm) if _bvm is not None else float(settings.MEAN_REVERSION_BEAR_VOLUME_MULT) + + +def _check_bear_buy_signal_v2_impl( + rsi: float, + close: float, + bb_upper: float, + bb_lower: float, + avg_volume_20d: float, + current_volume: int, + is_reversal: bool, + rsi_prev: Optional[float], + bear_rsi_threshold: float, + cfg_get: Callable[[str], float], + bear_vol_mult_fn: Callable[[], float], +) -> Optional[Dict]: + """BEAR v2 점수제 본체 — 파라미터 출처를 호출부가 주입한다(라이브: strategy_cfg/settings, + 백테스트: params/settings). 이 함수 자체는 strategy_cfg·adaptive 를 읽지 않는다.""" + from app.core.config import settings + # ── 필수 ①: RSI 상한 ──────────────────────────────────────────────────── - rsi_relax = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_RSI_RELAX") - rsi_hard_cap = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_RSI_HARD_CAP") + rsi_relax = cfg_get("MEAN_REVERSION_BEAR_V2_RSI_RELAX") + rsi_hard_cap = cfg_get("MEAN_REVERSION_BEAR_V2_RSI_HARD_CAP") rsi_upper = min(float(bear_rsi_threshold) + rsi_relax, rsi_hard_cap) if not (rsi < rsi_upper): return None @@ -374,20 +420,19 @@ def check_bear_buy_signal_v2( pct_b_max = 0.20 # ── 필수 ③: %B 하드 상한 (BB 중심선 위 매수 금지) ─────────────────────── - pct_b_hard_cap = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_PCT_B_HARD_CAP") + pct_b_hard_cap = cfg_get("MEAN_REVERSION_BEAR_V2_PCT_B_HARD_CAP") if pct_b > pct_b_hard_cap: return None - w_pct_b = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_PCT_B") - w_reversal = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_REVERSAL") - w_volume = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_VOLUME") - w_depth = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_W_RSI_DEPTH") - pct_b_relax_mult = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_PCT_B_RELAX_MULT") - vol_mid_mult = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_VOLUME_MID_MULT") - min_score = _bear_v2_cfg("MEAN_REVERSION_BEAR_V2_MIN_SCORE") + w_pct_b = cfg_get("MEAN_REVERSION_BEAR_V2_W_PCT_B") + w_reversal = cfg_get("MEAN_REVERSION_BEAR_V2_W_REVERSAL") + w_volume = cfg_get("MEAN_REVERSION_BEAR_V2_W_VOLUME") + w_depth = cfg_get("MEAN_REVERSION_BEAR_V2_W_RSI_DEPTH") + pct_b_relax_mult = cfg_get("MEAN_REVERSION_BEAR_V2_PCT_B_RELAX_MULT") + vol_mid_mult = cfg_get("MEAN_REVERSION_BEAR_V2_VOLUME_MID_MULT") + min_score = cfg_get("MEAN_REVERSION_BEAR_V2_MIN_SCORE") - _bvm = strategy_cfg.get("MEAN_REVERSION_BEAR_VOLUME_MULT") - bear_vol_mult = float(_bvm) if _bvm is not None else settings.MEAN_REVERSION_BEAR_VOLUME_MULT + bear_vol_mult = float(bear_vol_mult_fn()) # ── 점수 합산 ─────────────────────────────────────────────────────────── score_pct_b = ( @@ -430,7 +475,7 @@ def check_bear_buy_signal_v2( "split_plan": [0.30, 0.30, 0.40], # 3회 분할 매수 (기존 BEAR 와 동일) "description": ( f"[BEARv2 score {total:.2f}≥{min_score:.2f}] RSI({rsi:.1f})<{rsi_upper:.1f} " - f"+ %%B({pct_b:.2f}/{pct_b_max:.2f}) + 거래량({vol_ratio:.2f}×) " + f"+ %B({pct_b:.2f}/{pct_b_max:.2f}) + 거래량({vol_ratio:.2f}×) " f"+ 반전({'Y' if is_reversal else 'N'}) (하락장 과매도, 점수제)" ), "bear_v2_score": round(total, 4), @@ -462,15 +507,241 @@ def check_buy_signal( [2026-03-15 CLAUDE.md 정합성 수정] - SIDEWAYS 국면: 진입 차단 (CLAUDE.md 6.1) - - BULL: RSI < 40, BB하단 -3%~+1.5%, 거래량 >= 20일평균 - - BEAR: RSI < 30, 종가 <= BB하단, 되돌림 캔들, 거래량 > 1.5×20일평균 + - BULL: RSI < MEAN_REVERSION_RSI_THRESHOLD(실효값) + %B 동적 임계, 거래량 >= 20일평균 + - BEAR: RSI < MEAN_REVERSION_BEAR_RSI_THRESHOLD(실효값) + %B 동적 임계 + 되돌림 캔들 + + 거래량 > MEAN_REVERSION_BEAR_VOLUME_MULT×20일평균 - RSI 반등 확인: rsi_prev 제공 시, RSI가 반등 중일 때만 진입 허용 + ⚠️ 임계는 실행 시각의 라이브 적응형 모드·strategy_cfg(DB)를 읽는다(§0 우선순위). + **백테스트는 이 함수를 쓰지 말고 `check_buy_signal_backtest_v2(params, ...)` 를 쓸 것** + (2026-10-09 하드닝 R2 — 같은 입력이 실행 시각마다 다른 결과를 내던 비결정성 제거). + [안전형 개선①] own_trend 게이트 (검증 완료: Sharpe 0.27→0.57) - ma20/ma60 제공 시: 종목 자체 MA20 < MA60*0.97 (하락추세)이면 진입 차단 (물타기 방지 — 시장 국면과 무관하게 개별 종목이 자체 하락추세면 평균회귀 매수 금지) - ma20/ma60 미제공(기본값 None): 게이트 미적용 — 기존 호출부/테스트 하위호환 유지 """ + return _check_buy_signal_impl( + regime=regime, + rsi=rsi, + close=close, + bb_upper=bb_upper, + bb_middle=bb_middle, + bb_lower=bb_lower, + avg_volume_20d=avg_volume_20d, + current_volume=current_volume, + prev_volume=prev_volume, + is_reversal=is_reversal, + rsi_prev=rsi_prev, + ma20=ma20, + ma60=ma60, + bull_rsi_fn=_live_bull_rsi_threshold_v2, + bb_scale_fn=_live_bb_pct_b_scale_v2, + bear_rsi_fn=_live_bear_rsi_threshold_v2, + bear_vol_mult_fn=_live_bear_volume_mult_v2, + bear_v2_enabled_fn=_bear_v2_enabled, + bear_v2_cfg_get=_bear_v2_cfg, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# 진입 파라미터 리졸버 — 라이브(adaptive > strategy_cfg > settings) +# ───────────────────────────────────────────────────────────────────────────── +_BB_PCT_B_NORMAL_REF = 0.30 # 적응형 bb_pct_b 의 Normal 기준값 (스케일 = bb_pct_b / 0.30) +_BB_PCT_B_SCALE_CAP = 1.3 # S1 fix: BB 중심선(pct_b=0.5) 위 진입 방지 — 스케일 상한 + + +def _bb_pct_b_to_scale_v2(bb_pct_b: float) -> float: + """적응형 bb_pct_b → %B 임계 스케일 (Normal 0.30 = 1.0, 상한 1.3). 라이브·백테스트 공용.""" + _scale = bb_pct_b / _BB_PCT_B_NORMAL_REF if bb_pct_b > 0 else 1.0 + return min(_scale, _BB_PCT_B_SCALE_CAP) + + +def _live_bull_rsi_threshold_v2() -> float: + from app.core.config import settings + _rsi_threshold_val = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD") + _base = float(_rsi_threshold_val) if _rsi_threshold_val is not None else float(settings.MEAN_REVERSION_RSI_THRESHOLD) + try: + from app.services.adaptive_strategy import get_current_adaptive_params + _adp = get_current_adaptive_params() + if not _adp.fallback_used: + _base = float(_adp.rsi_entry) + except Exception as _adp_err: + # [§4 2026-10-02] config/DB 폴백 유지 + warning(레이트리밋) + warn_throttled(logger, "mean_reversion:bull_rsi_overlay", + "[MeanReversion] 적응형 BULL RSI 임계 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err) + return _base + + +def _live_bb_pct_b_scale_v2() -> float: + _scale = 1.0 + try: + from app.services.adaptive_strategy import get_current_adaptive_params + _adp_bb = get_current_adaptive_params() + if not _adp_bb.fallback_used: + _scale = _bb_pct_b_to_scale_v2(_adp_bb.bb_pct_b) + except Exception as _adp_err: + _scale = 1.0 + warn_throttled(logger, "mean_reversion:bb_scale_overlay", + "[MeanReversion] 적응형 %%B 스케일 조회 실패 — 1.0 사용: %s", _adp_err) + return _scale + + +def _live_bear_rsi_threshold_v2() -> float: + from app.core.config import settings + _bear_rsi_threshold = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD") + _th = float(_bear_rsi_threshold) if _bear_rsi_threshold is not None else float(settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD) + try: + from app.services.adaptive_strategy import get_current_adaptive_params + _adp_bear = get_current_adaptive_params() + if not _adp_bear.fallback_used: + _th = float(_adp_bear.bear_rsi_entry) + except Exception as _adp_err: + warn_throttled(logger, "mean_reversion:bear_rsi_overlay", + "[MeanReversion] 적응형 BEAR RSI 임계 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err) + return _th + + +# ───────────────────────────────────────────────────────────────────────────── +# 백테스트 전용 진입 판정 — 라이브 적응형 모드·DB(strategy_cfg)를 읽지 않는다 +# ───────────────────────────────────────────────────────────────────────────── +def _bt_entry_param_v2(params: Dict[str, Any], key: str, default: float, + lo: float, hi: float) -> float: + """백테스트 진입 파라미터: params[key] 가 유한하고 (lo, hi] 안이면 그 값, 아니면 default(+warning). + + default 는 항상 settings(config.py) 값이다 — strategy_cfg(DB)·adaptive 는 보지 않는다. + """ + val = params.get(key) if params else None + if val is None: + return float(default) + try: + f = float(val) + except (TypeError, ValueError): + f = float("nan") + if f != f or f in (float("inf"), float("-inf")) or not (lo < f <= hi): + logger.warning("[MeanReversionBT] 진입 파라미터 %s=%r 비정상 — 기본값 %s 사용", key, val, default) + return float(default) + return f + + +def _bt_bool_param_v2(params: Dict[str, Any], key: str, default: bool) -> bool: + val = params.get(key) if params else None + if val is None: + return bool(default) + if isinstance(val, str): + return val.strip().lower() in ("1", "true", "yes", "on") + return bool(val) + + +def check_buy_signal_backtest_v2( + params: Optional[Dict[str, Any]], + regime: MarketRegime, + rsi: float, + close: float, + bb_upper: float, + bb_middle: float, + bb_lower: float, + avg_volume_20d: float, + current_volume: int, + prev_volume: int, + is_reversal: bool = False, + rsi_prev: Optional[float] = None, + ma20: Optional[float] = None, + ma60: Optional[float] = None, +) -> Optional[Dict]: + """백테스트 전용 진입 판정 (결정론) — `check_buy_signal` 과 로직 동일, 파라미터 출처만 다르다. + + 라이브 `check_buy_signal` 은 `get_current_adaptive_params()`(실행 시각의 라이브 모드, TTL 초과 시 + 백그라운드 refresh 로 네이버/KIS 조회까지 기동)와 `strategy_cfg`(운영 DB)를 읽어, 같은 입력이라도 + 실행 시각마다 결과가 달랐다. 이 함수는 둘 다 읽지 않는다. 모든 진입 임계는 `params` 로 명시 + 주입되고, 없으면 settings(config.py) 기본값이다 — 적응형 모드 미적용 = Normal/폴백 경로와 동일. + + params 키 (모두 선택): + · rsi_entry BULL RSI 진입 임계 (기본 MEAN_REVERSION_RSI_THRESHOLD) + · bear_rsi_entry BEAR RSI 진입 임계 (기본 MEAN_REVERSION_BEAR_RSI_THRESHOLD) + · bb_pct_b 적응형 모드의 bb_pct_b — %B 임계 스케일 = bb_pct_b/0.30 (상한 1.3). + 미지정이면 스케일 1.0 + · bear_volume_mult BEAR 거래량 배수 (기본 MEAN_REVERSION_BEAR_VOLUME_MULT) + · bear_v2_enabled BEAR 점수제 v2 (기본 settings.MEAN_REVERSION_BEAR_V2_ENABLED) + · MEAN_REVERSION_BEAR_V2_* (config 키 이름 그대로) — v2 가중치·임계 오버라이드, 기본 settings + 모드별 백테스트는 봉마다 해당 모드의 rsi_entry/bear_rsi_entry/bb_pct_b 를 params 로 넘긴다 + (run_backtest.determine_adaptive_mode 방식). + """ + from app.core.config import settings + _p: Dict[str, Any] = dict(params or {}) + + def _bear_v2_cfg_bt(key: str) -> float: + _v = _p.get(key) + if _v is not None: + return float(_v) + return float(getattr(settings, key)) + + def _bull_rsi() -> float: + return _bt_entry_param_v2(_p, "rsi_entry", settings.MEAN_REVERSION_RSI_THRESHOLD, 0.0, 100.0) + + def _bear_rsi() -> float: + return _bt_entry_param_v2(_p, "bear_rsi_entry", settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD, 0.0, 100.0) + + def _bb_scale() -> float: + if _p.get("bb_pct_b") is None: + return 1.0 + return _bb_pct_b_to_scale_v2( + _bt_entry_param_v2(_p, "bb_pct_b", _BB_PCT_B_NORMAL_REF, 0.0, 1.0)) + + def _bear_vol() -> float: + return _bt_entry_param_v2(_p, "bear_volume_mult", settings.MEAN_REVERSION_BEAR_VOLUME_MULT, 0.0, 10.0) + + def _v2_enabled() -> bool: + return _bt_bool_param_v2(_p, "bear_v2_enabled", settings.MEAN_REVERSION_BEAR_V2_ENABLED) + + return _check_buy_signal_impl( + regime=regime, + rsi=rsi, + close=close, + bb_upper=bb_upper, + bb_middle=bb_middle, + bb_lower=bb_lower, + avg_volume_20d=avg_volume_20d, + current_volume=current_volume, + prev_volume=prev_volume, + is_reversal=is_reversal, + rsi_prev=rsi_prev, + ma20=ma20, + ma60=ma60, + bull_rsi_fn=_bull_rsi, + bb_scale_fn=_bb_scale, + bear_rsi_fn=_bear_rsi, + bear_vol_mult_fn=_bear_vol, + bear_v2_enabled_fn=_v2_enabled, + bear_v2_cfg_get=_bear_v2_cfg_bt, + ) + + +def _check_buy_signal_impl( + regime: MarketRegime, + rsi: float, + close: float, + bb_upper: float, + bb_middle: float, + bb_lower: float, + avg_volume_20d: float, + current_volume: int, + prev_volume: int, + is_reversal: bool, + rsi_prev: Optional[float], + ma20: Optional[float], + ma60: Optional[float], + bull_rsi_fn: Callable[[], float], + bb_scale_fn: Callable[[], float], + bear_rsi_fn: Callable[[], float], + bear_vol_mult_fn: Callable[[], float], + bear_v2_enabled_fn: Callable[[], bool], + bear_v2_cfg_get: Callable[[str], float], +) -> Optional[Dict]: + """평균회귀 진입 판정 본체. 파라미터는 전부 주입된 리졸버(지연 호출)로만 얻는다 — + 이 함수 자체는 strategy_cfg·adaptive 를 읽지 않는다(라이브/백테스트가 리졸버로 출처를 고른다).""" + from app.core.config import settings + # CLAUDE.md 6.1: 횡보장(SIDEWAYS)에서는 모든 진입 신호를 무시 if regime == MarketRegime.SIDEWAYS: logger.debug("⏭️ SIDEWAYS 국면 — 진입 차단 (CLAUDE.md 6.1)") @@ -489,20 +760,8 @@ def check_buy_signal( if regime == MarketRegime.BULL: # CLAUDE.md §6 / §8.2: BULL 진입 RSI < 46 (MEAN_REVERSION_RSI_THRESHOLD=46.0) - # 파라미터 우선순위: adaptive_strategy > strategy_cfg(DB) > settings(config.py) - # [2026-09-27] 하드코딩 40.0 폴백 제거 → settings 참조 (BEAR 경로와 동일 패턴) - from app.core.config import settings - _rsi_threshold_val = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD") - _base_rsi_threshold = float(_rsi_threshold_val) if _rsi_threshold_val is not None else settings.MEAN_REVERSION_RSI_THRESHOLD - try: - from app.services.adaptive_strategy import get_current_adaptive_params - _adp = get_current_adaptive_params() - if not _adp.fallback_used: - _base_rsi_threshold = _adp.rsi_entry - except Exception as _adp_err: - # [§4 2026-10-02] config/DB 폴백 유지 + warning(레이트리밋) - warn_throttled(logger, "mean_reversion:bull_rsi_overlay", - "[MeanReversion] 적응형 BULL RSI 임계 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err) + # 임계 출처는 리졸버(라이브: adaptive > strategy_cfg > settings / 백테스트: params > settings) + _base_rsi_threshold = float(bull_rsi_fn()) rsi_ok = rsi < _base_rsi_threshold if rsi_ok: @@ -514,21 +773,8 @@ def check_buy_signal( ) return None - # %B 기반 동적 BB 조건 (RSI 깊이에 따라 임계값 조정) - # 적응형 전략: bb_pct_b 오버라이드로 기본 pct_b_max 스케일 조정 - _adaptive_bb_scale = 1.0 # 기본 스케일 - try: - from app.services.adaptive_strategy import get_current_adaptive_params - _adp_bb = get_current_adaptive_params() - if not _adp_bb.fallback_used: - # Normal 기준 0.30 대비 비율로 스케일링 - _adaptive_bb_scale = _adp_bb.bb_pct_b / 0.30 if _adp_bb.bb_pct_b > 0 else 1.0 - # S1 fix: BB 중심선(pct_b=0.5) 위 진입 방지 — 스케일 상한 1.3 - _adaptive_bb_scale = min(_adaptive_bb_scale, 1.3) - except Exception as _adp_err: - _adaptive_bb_scale = 1.0 - warn_throttled(logger, "mean_reversion:bb_scale_overlay", - "[MeanReversion] 적응형 %%B 스케일 조회 실패 — 1.0 사용: %s", _adp_err) + # %B 기반 동적 BB 조건 (RSI 깊이에 따라 임계값 조정) × 적응형 스케일(상한 1.3) + _adaptive_bb_scale = float(bb_scale_fn()) bb_width = bb_upper - bb_lower pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5 @@ -547,10 +793,7 @@ def check_buy_signal( "signal": "BUY", "strategy": "MEAN_REVERSION_BULL", "split_plan": [0.50, 0.50], # 2회 분할 매수 - # [2026-09-27] 임계값 40 하드코딩 제거 — 실효 임계값은 _base_rsi_threshold - # (adaptive > strategy_cfg > settings 우선순위, CLAUDE.md §0)이며 46 이 기본이다. - # 40 을 박아두면 로그·TradeJournal·ML 학습 데이터에 거짓 임계값이 남는다. - # `%%B` → `%B` 도 정정(f-string 은 % 를 이스케이프하지 않아 그대로 출력됐다). + # [2026-09-27] 임계값 하드코딩 제거 — 실효 임계값(_base_rsi_threshold)을 기록한다. "description": ( f"RSI({rsi:.1f})<{_base_rsi_threshold:.0f} + " f"%B({pct_b:.2f})<{pct_b_max:.2f} (상승장 과매도){reversal_info}" @@ -559,22 +802,13 @@ def check_buy_signal( elif regime == MarketRegime.BEAR: # CLAUDE.md §6 / §8.2: BEAR 진입 RSI < 35 (MEAN_REVERSION_BEAR_RSI_THRESHOLD=35.0) - # 파라미터 우선순위: adaptive_strategy > strategy_cfg(DB) > settings(config.py) - from app.core.config import settings - _bear_rsi_threshold = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD") - bear_rsi_threshold = float(_bear_rsi_threshold) if _bear_rsi_threshold is not None else settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD - try: - from app.services.adaptive_strategy import get_current_adaptive_params - _adp_bear = get_current_adaptive_params() - if not _adp_bear.fallback_used: - bear_rsi_threshold = _adp_bear.bear_rsi_entry - except Exception as _adp_err: - warn_throttled(logger, "mean_reversion:bear_rsi_overlay", - "[MeanReversion] 적응형 BEAR RSI 임계 조회 실패 — strategy_cfg/config 값 사용: %s", _adp_err) + bear_rsi_threshold = float(bear_rsi_fn()) # [§8.5-9] BEAR 점수제 v2 — 기본 OFF. OFF면 아래 기존 4중 AND 경로가 그대로 동작한다. - if _bear_v2_enabled(): - return check_bear_buy_signal_v2( + # ⚠️ 이 분기(및 아래 4중 AND)는 백테스트 전용이다(check_buy_signal 호출부 = 백테스트 엔진 2곳). + # 실전 BEAR 진입은 recommendations.is_mean_reversion_candidate 가 판정하며 v2 플래그를 보지 않는다. + if bear_v2_enabled_fn(): + return _check_bear_buy_signal_v2_impl( rsi=rsi, close=close, bb_upper=bb_upper, @@ -584,6 +818,8 @@ def check_buy_signal( is_reversal=is_reversal, rsi_prev=rsi_prev, bear_rsi_threshold=bear_rsi_threshold, + cfg_get=bear_v2_cfg_get, + bear_vol_mult_fn=bear_vol_mult_fn, ) if rsi < bear_rsi_threshold: @@ -605,8 +841,7 @@ def check_buy_signal( else: pct_b_max = 0.20 - _bear_vol_mult = strategy_cfg.get("MEAN_REVERSION_BEAR_VOLUME_MULT") - bear_vol_mult = float(_bear_vol_mult) if _bear_vol_mult is not None else settings.MEAN_REVERSION_BEAR_VOLUME_MULT + bear_vol_mult = float(bear_vol_mult_fn()) if pct_b <= pct_b_max and is_reversal and current_volume > bear_vol_mult * avg_volume_20d: return { @@ -676,6 +911,118 @@ def resolve_swing_stop_params_v2(atr: Optional[float], close: float) -> Tuple[fl return stop_loss_pct, float(atr_stop_mult) +def _trailing_mult_range_v2() -> Tuple[float, float]: + """ATR 트레일링 배수 허용 범위 — strategy_configs 메타(DEFAULT_PARAMS min/max)가 단일 출처. + + 메타를 읽을 수 없으면 (0, inf) 대신 config 값 하나만 허용하는 효과가 나도록 + 호출부가 config 로 폴백한다(여기서는 넓은 범위를 만들지 않는다). + """ + from app.services.strategy_config_service import DEFAULT_PARAMS + _meta = DEFAULT_PARAMS["ATR_TRAILING_MULTIPLIER"] + return float(_meta[5]), float(_meta[6]) + + +def _trailing_mult_or_none_v2(val, label: str) -> Optional[float]: + """트레일링 배수 검증 — 범위 안의 유한한 양수면 float, 아니면 None(+warning).""" + if val is None: + return None + try: + f = float(val) + except (TypeError, ValueError): + warn_throttled(logger, f"mean_reversion:bad_trail_mult:{label}", + "[MeanReversion] 트레일링 배수 %s=%r 해석 불가 — 하위 소스 값 사용", label, val) + return None + if f != f or f in (float("inf"), float("-inf")): + warn_throttled(logger, f"mean_reversion:bad_trail_mult:{label}", + "[MeanReversion] 트레일링 배수 %s=%r 비정상 — 하위 소스 값 사용", label, val) + return None + try: + lo, hi = _trailing_mult_range_v2() + except Exception as _meta_err: + warn_throttled(logger, "mean_reversion:trail_mult_meta", + "[MeanReversion] 트레일링 배수 범위 메타 조회 실패 — 하위 소스 값 사용: %s", _meta_err) + return None + if not (lo <= f <= hi): + warn_throttled(logger, f"mean_reversion:bad_trail_mult:{label}", + "[MeanReversion] 트레일링 배수 %s=%r 범위[%s~%s] 밖 — 하위 소스 값 사용", + label, val, lo, hi) + return None + return f + + +def _adaptive_trailing_enabled_v2() -> bool: + """적응형 트레일링 배수 적용 스위치. config 키 SWING_ADAPTIVE_TRAILING_ENABLED(미정의 시 False). + + 백테스트로 적응형 2.0/1.6/1.3/1.0 을 검증하기 전에는 False 유지(동작 = 1.5 고정과 동일). + """ + from app.core.config import settings + return getattr(settings, "SWING_ADAPTIVE_TRAILING_ENABLED", False) is True + + +def get_swing_trailing_mult_status_v2() -> Dict[str, object]: + """상태 API/UI 용 — 실효 트레일링 배수와 적응형 값 적용 여부. + + adaptive_strategy 상태 응답의 atr_trailing_multiplier(1.6 등)는 SWING_ADAPTIVE_TRAILING_ENABLED + 가 False 면 **미적용**이다. 화면에 이 함수의 결과를 함께 노출해 혼동을 막는다. + """ + mult, source = _resolve_swing_trailing_atr_mult_impl() + return { + "effective_atr_trailing_multiplier": mult, + "source": source, + "adaptive_trailing_applied": source == "adaptive", + "adaptive_trailing_enabled": _adaptive_trailing_enabled_v2(), + } + + +def _resolve_swing_trailing_atr_mult_impl() -> Tuple[float, str]: + """스윙 ATR 트레일링 배수 → (배수, 출처["config"|"strategy_cfg"|"adaptive"]). + + 우선순위(CLAUDE.md §0): adaptive > strategy_cfg(DB) > settings(config.py). + 단 **적응형 값은 SWING_ADAPTIVE_TRAILING_ENABLED(기본 False) 일 때만** 적용한다. + 적응형 _MODE_PARAMS 의 2.0/1.6/1.3/1.0 은 실전 동작을 바꾸므로 백테스트 전에는 + 켜지 않는다(이전 동작 = 항상 1.5). config/DB 기본값은 1.5 로 이전 리터럴과 동일하다. + 예외 없이 항상 유효한 양수를 반환한다(최종 폴백은 config). + """ + from app.core.config import settings + mult = float(settings.ATR_TRAILING_MULTIPLIER) + source = "config" + _cfg = _trailing_mult_or_none_v2(strategy_cfg.get("ATR_TRAILING_MULTIPLIER"), + "strategy_cfg.ATR_TRAILING_MULTIPLIER") + if _cfg is not None: + mult, source = _cfg, "strategy_cfg" + + if _adaptive_trailing_enabled_v2(): + _adp = None + try: + from app.services.adaptive_strategy import get_current_adaptive_params + _adp = get_current_adaptive_params() + except Exception as _adp_err: + warn_throttled(logger, "mean_reversion:trail_mult_overlay", + "[MeanReversion] 적응형 트레일링 배수 조회 실패 — strategy_cfg/config 값 사용: %s", + _adp_err) + _adp = None + if _adp is not None and not getattr(_adp, "fallback_used", True): + _adp_mult = _trailing_mult_or_none_v2(getattr(_adp, "atr_trailing_multiplier", None), + "adaptive.atr_trailing_multiplier") + if _adp_mult is not None: + mult, source = _adp_mult, "adaptive" + + # 최종 가드: config 자체가 비정상(0·음수·NaN)이면 트레일링 스탑이 고점 이상(즉시 청산)이 + # 되거나 의미를 잃는다 → strategy_configs 메타의 문서화 기본값으로 대체(+error). + if not (mult > 0) or mult != mult: + from app.services.strategy_config_service import DEFAULT_PARAMS + _default = float(DEFAULT_PARAMS["ATR_TRAILING_MULTIPLIER"][4]) + logger.error("[MeanReversion] ATR 트레일링 배수 비정상(%r, 출처=%s) — 메타 기본값 %s 사용", + mult, source, _default) + mult, source = _default, "meta_default" + return float(mult), source + + +def resolve_swing_trailing_atr_mult_v2() -> float: + """스윙 트레일링 스탑 배수(고점 − 배수×ATR) 단일 출처. 상세는 _impl 참조.""" + return _resolve_swing_trailing_atr_mult_impl()[0] + + def _stop_param_or_none_v2(val, label: str) -> Optional[float]: """손절 파라미터 값 검증 — 합리 범위 안의 유한한 숫자면 float, 아니면 None(+warning). @@ -735,15 +1082,27 @@ def check_exit_signal( - 분할매도 비율: 50% (Bull/Bear 동일, CLAUDE.md 5.2) - 트레일링 스탑: 고점 - 1.5 * ATR (CLAUDE.md 5.2) - 시나리오 (실제 동작 — 2026-10-02 정정): + 시나리오 (실제 동작 — 2026-10-02 정정, 2026-10-09 R4 재정정): 1. 손절 체크 (최우선) 2. 1차 익절 (remaining_pct > 0.50 AND 수익 >= 0.5%) └─ RSI >= rsi_partial_exit(기본 MEAN_REVERSION_RSI_EXIT_THRESHOLD) OR BB중앙선 상회 OR 2×ATR 수익: 50% 매도 (sell_pct = 원본 수량 대비) 3. 전량 청산 - └─ RSI >= rsi_full_exit OR BB 상단×MEAN_REVERSION_BB_UPPER_THRESHOLD 도달 (단계 무관) - OR 트레일링(고점 − 1.5×ATR 하회, **1차 익절 후 remaining_pct ≤ 0.50 에서만**) + └─ RSI >= rsi_full_exit OR BB 상단×MEAN_REVERSION_BB_UPPER_THRESHOLD 도달 + OR 트레일링(고점 − 배수×ATR 하회 — 배수는 resolve_swing_trailing_atr_mult_v2, 기본 1.5; **1차 익절 후 remaining_pct ≤ 0.50 에서만**) → 보유 전량 + + ⚠️ 단계 우선순위 (R4 2026-10-09 — "단계 무관"은 사실이 아니었다): + 1차 익절 전(remaining_pct > 0.50) + 수익 ≥ 0.5% 이면 **2단계가 먼저 return** 한다. + RSI ≥ rsi_full_exit 이면 RSI ≥ rsi_partial_exit 도, BB상단×임계 도달이면 BB중앙 상회도 + 항상 함께 참이므로, 이 구간의 전량 조건은 **50% 분할로 처리**된다. 잔량은 다음 사이클 + (swing/exit_service 쿨다운 후)에 전량 조건이 여전히 성립할 때 청산되고, 그 사이 되돌리면 + 트레일링 관리로 넘어간다. 전량 조건이 단계와 무관하게 즉시 전량으로 작동하는 것은 + 1차 익절 이후(remaining_pct ≤ 0.50) 인 경우뿐이다. + [R6 2026-10-10] RSI/BB상단 전량 조건도 수익 ≥ 0.5%(_MIN_PROFIT_FOR_EXIT) 일 때만 성립한다. + 수익 < 0.5%(손실 포함)에서는 손절·시간손절·(1차 익절 후) 트레일링만 청산한다. + 순서를 바꾸면 청산 타이밍이 달라지므로 백테스트(§8.1-5) 없이 변경하지 말 것. + (kr_backtest_engine 의 v2 청산도 같은 순서다.) """ # ── 1. 손절 (최우선) ────────────────────────────────────────── from app.core.config import settings @@ -821,22 +1180,30 @@ def check_exit_signal( # ── 3. 전량매도 조건 ─────────────────────────────────────────────── # CLAUDE.md 6.3: RSI >= 전량청산 임계(rsi_full_exit / MEAN_REVERSION_RSI_FULL_EXIT) # OR 가격 >= BB 상단 × MEAN_REVERSION_BB_UPPER_THRESHOLD OR 트레일링스탑 도달 - # [2026-10-02 §5.2 "잔량 트레일링"] 트레일링(고점 − 1.5×ATR)은 1차 분할 익절 **이후** + # [2026-10-02 §5.2 "잔량 트레일링"] 트레일링(고점 − 배수×ATR)은 1차 분할 익절 **이후** # (remaining_pct ≤ 0.50 — 분할 단계 기록값, sizing_core.remaining_pct_after_partial_sell) # 에만 적용한다. 이전엔 remaining_pct 와 무관하게 1차 익절 전에도 보유 전량을 청산했다. # 1차 익절 전 하락은 손절(1단계)이 담당한다. from app.core.config import settings full_exit_reasons = [] - if rsi >= _adaptive_rsi_full: + # [R6 2026-10-10] RSI/BB상단 전량 '익절' 조건에도 1차 익절과 같은 최소수익 가드 + # (_MIN_PROFIT_FOR_EXIT) 를 둔다. 이전엔 손실 구간(예: trade_history id 29, -0.42%)에서도 + # '전량청산: BB상단 도달'로 손실을 확정하고 loss_tracker 연패에 쌓였다. + # 손실 구간 청산은 손절(1단계)·시간손절(exit_service)·트레일링(아래, 가드 없음)이 맡는다. + _full_take_profit_ok = _profit_pct >= _MIN_PROFIT_FOR_EXIT + if _full_take_profit_ok and rsi >= _adaptive_rsi_full: full_exit_reasons.append(f"RSI({rsi:.1f})>={_adaptive_rsi_full}") - if close >= bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD: + if _full_take_profit_ok and close >= bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD: full_exit_reasons.append(f"BB상단({bb_upper:.0f})도달") _partial_done = remaining_pct <= threshold + 1e-9 if _partial_done and trailing_high is not None and trailing_high > entry_price: - trail_stop = trailing_high - 1.5 * atr # CLAUDE.md 5.2: ATR 기반 트레일링 + # [R3 2026-10-09] 리터럴 1.5 → resolve_swing_trailing_atr_mult_v2 + # (strategy_cfg > config, 적응형은 SWING_ADAPTIVE_TRAILING_ENABLED 일 때만). 기본값 동일. + _trail_mult = resolve_swing_trailing_atr_mult_v2() + trail_stop = trailing_high - _trail_mult * atr # CLAUDE.md 5.2: ATR 기반 트레일링 if close < trail_stop: full_exit_reasons.append( - f"트레일링(close={close:.0f}<{trail_stop:.0f})" + f"트레일링(close={close:.0f}<{trail_stop:.0f}=고점-{_trail_mult:g}×ATR)" ) if full_exit_reasons: diff --git a/backend/app/services/ml_retrain_pipeline.py b/backend/app/services/ml_retrain_pipeline.py index 2064de2..da781f1 100644 --- a/backend/app/services/ml_retrain_pipeline.py +++ b/backend/app/services/ml_retrain_pipeline.py @@ -389,6 +389,35 @@ async def _load_daily_prices( return pd.DataFrame(records) +def _resolve_trade_price_v2(row) -> float: + """체결가 NULL 규약 대응: executed_price -> total_amount/executed_quantity -> order_price -> 0.""" + ep = getattr(row, "executed_price", None) + if ep: + return float(ep) + amt = getattr(row, "total_amount", None) + qty = getattr(row, "executed_quantity", None) + if amt and qty: + return float(amt) / float(qty) + op = getattr(row, "order_price", None) + return float(op) if op else 0.0 + + +# 체결 확정 상태. 운영 trade_history 는 COMPLETED 만 존재(EXECUTED 0건)하므로 +# EXECUTED 단독 필터는 성능 저하 감시를 영구히 '데이터 부족'으로 만들었다. EXECUTED 는 레거시 호환. +_FILLED_TRADE_STATUSES = ("COMPLETED", "EXECUTED") + +# 성능 저하 재학습을 허용하는 장외 시간대(KST): 주말 전일, 평일 20:00~익일 06:59 +_RETRAIN_SAFE_START_HOUR = 20 +_RETRAIN_SAFE_END_HOUR = 7 + + +def _is_retrain_safe_window_v2(now: datetime) -> bool: + """장중이 아닌(주말·야간) 시각이면 True. 인프로세스 재학습 허용 시간대.""" + if now.weekday() >= 5: + return True + return now.hour >= _RETRAIN_SAFE_START_HOUR or now.hour < _RETRAIN_SAFE_END_HOUR + + async def _load_trade_history( months: int = ROLLING_WINDOW_MONTHS, ) -> pd.DataFrame: @@ -411,7 +440,7 @@ async def _load_trade_history( select(TradeHistory) .where( and_( - TradeHistory.status == "EXECUTED", + TradeHistory.status.in_(_FILLED_TRADE_STATUSES), TradeHistory.created_at >= cutoff, ) ) @@ -428,7 +457,7 @@ async def _load_trade_history( records.append({ "stock_code": row.stock_code, "order_type": row.order_type, - "executed_price": float(row.executed_price) if row.executed_price else 0, + "executed_price": _resolve_trade_price_v2(row), "profit_loss_rate": float(row.profit_loss_rate) if row.profit_loss_rate else 0, "created_at": row.created_at, }) @@ -489,6 +518,30 @@ async def check_performance_degradation() -> Tuple[bool, Dict[str, Any]]: # ─── 메인 재학습 실행 ────────────────────────────────────────────────────── +def _build_training_lists_impl( + price_df: pd.DataFrame, +) -> Tuple[List[np.ndarray], List[np.ndarray]]: + """종목별 피처·라벨 구성(동기, CPU 바운드). 반드시 to_thread 로 호출한다.""" + all_X_list: List[np.ndarray] = [] + all_y_list: List[np.ndarray] = [] + for code in price_df["stock_code"].unique(): + code_df = price_df[price_df["stock_code"] == code].copy() + if len(code_df) < 80: # 최소 80일 필요 (60일 피처 + 20일 여유) + continue + code_df = code_df.reset_index(drop=True) + try: + features = _compute_retrain_features(code_df) + labels = _prepare_retrain_labels(code_df["close"].values.astype(float)) + X, y = _build_training_dataset(features, labels) + if len(X) > 0: + all_X_list.append(X) + all_y_list.append(y) + except Exception as e: + warn_throttled(logger, "ml_retrain_pipeline:540", f"종목 {code} 피처 추출 실패: {e}") + continue + return all_X_list, all_y_list + + async def run_retrain( model_dir: Optional[Path] = None, force: bool = False, @@ -519,28 +572,10 @@ async def run_retrain( stock_codes = price_df["stock_code"].unique() logger.info(f"로드된 종목 수: {len(stock_codes)}, 총 행: {len(price_df)}") - # 2. 종목별 피처 + 라벨 생성 - all_X_list: List[np.ndarray] = [] - all_y_list: List[np.ndarray] = [] - - for code in stock_codes: - code_df = price_df[price_df["stock_code"] == code].copy() - if len(code_df) < 80: # 최소 80일 필요 (60일 피처 + 20일 여유) - continue - - code_df = code_df.reset_index(drop=True) - - try: - features = _compute_retrain_features(code_df) - labels = _prepare_retrain_labels(code_df["close"].values.astype(float)) - X, y = _build_training_dataset(features, labels) - - if len(X) > 0: - all_X_list.append(X) - all_y_list.append(y) - except Exception as e: - warn_throttled(logger, "ml_retrain_pipeline:540", f"종목 {code} 피처 추출 실패: {e}") - continue + # 2. 종목별 피처 + 라벨 생성 (CPU 바운드 — 이벤트 루프 보호를 위해 워커 스레드) + all_X_list, all_y_list = await asyncio.to_thread( + _build_training_lists_impl, price_df + ) if not all_X_list: msg = "재학습 중단: 유효 학습 데이터 없음" @@ -617,6 +652,7 @@ async def ml_retrain_scheduler(): last_scheduled_retrain: Optional[date] = None last_degradation_check: Optional[date] = None + retrain_pending = False while True: try: @@ -637,23 +673,33 @@ async def ml_retrain_scheduler(): except Exception as e: logger.error(f"주기적 재학습 실패: {e}", exc_info=True) - # ── 성능 저하 감지: 매일 09:30 KST (장 시작 전) ── + # ── 성능 저하 감지: 매일 09:30 KST. 감지만 하고 재학습은 장외로 미룬다 ── + # (인프로세스 재학습은 이벤트 루프·메모리를 점유해 손절/EOD 루프에 영향) if ( - now.hour >= 9 - and now.minute >= 30 + (now.hour, now.minute) >= (9, 30) and last_degradation_check != today ): last_degradation_check = today try: degraded, details = await check_performance_degradation() if degraded: - logger.warning(f"성능 저하 감지 -> 자동 재학습 트리거: {details}") - result = await run_retrain(force=True) - logger.info(f"성능 저하 재학습 결과: {result.get('status')}") + retrain_pending = True + logger.warning( + f"ML 성능 저하 감지 -> 재학습 필요 플래그 설정 (장외에 실행): {details}" + ) else: - logger.debug(f"성능 저하 미감지: {details}") + logger.info(f"성능 저하 미감지: {details}") except Exception as e: - logger.error(f"성능 저하 감지/재학습 실패: {e}", exc_info=True) + logger.error(f"성능 저하 감지 실패: {e}", exc_info=True) + + # ── 보류된 성능 저하 재학습: 주말·야간에만 실행 ── + if retrain_pending and _is_retrain_safe_window_v2(now): + retrain_pending = False + try: + result = await run_retrain(force=True) + logger.info(f"성능 저하 재학습 결과: {result.get('status')}") + except Exception as e: + logger.error(f"성능 저하 재학습 실패: {e}", exc_info=True) except asyncio.CancelledError: logger.info("ML 재학습 스케줄러 종료") diff --git a/backend/app/services/naver_finance.py b/backend/app/services/naver_finance.py index 427e096..8e00e97 100644 --- a/backend/app/services/naver_finance.py +++ b/backend/app/services/naver_finance.py @@ -187,10 +187,44 @@ async def get_naver_stock_list( _CHART_URL = "https://fchart.stock.naver.com/siseJson.naver" +# 호가단위 계수에서 제외할 ETF/ETN/벤치마크 코드. 종목명을 모르는 이 모듈은 코드로만 판정하므로 +# 배치/호출부가 register_etf_codes_v2 로 알려주거나 get_naver_daily_chart_v2(is_etf=True) 로 넘긴다. +_BENCHMARK_ETF_CODES = frozenset({"069500", "122630", "252670", "114800"}) +_registered_etf_codes: set = set() + + +def register_etf_codes_v2(codes) -> int: + """ETF/ETN 코드를 등록해 이후 차트 조회의 호가단위 계수에서 제외한다. 등록 후 총 개수 반환.""" + for c in codes or []: + if c: + _registered_etf_codes.add(str(c).strip()) + return len(_registered_etf_codes) + + +def is_non_stock_code_v2(stock_code: str) -> bool: + """주식 호가단위로 검사하면 안 되는 코드인가(벤치마크·등록 ETF·6자리 숫자가 아닌 코드).""" + code = str(stock_code or "").strip() + return ( + code in _BENCHMARK_ETF_CODES + or code in _registered_etf_codes + or not (len(code) == 6 and code.isdigit()) + ) + + async def get_naver_daily_chart( stock_code: str, days: int = 80, client: Optional[httpx.AsyncClient] = None, +) -> Optional[List[Dict]]: + """기존 시그니처 유지 래퍼. ETF 여부는 코드 기반(is_non_stock_code_v2)으로 판정한다.""" + return await get_naver_daily_chart_v2(stock_code, days=days, client=client) + + +async def get_naver_daily_chart_v2( + stock_code: str, + days: int = 80, + client: Optional[httpx.AsyncClient] = None, + is_etf: Optional[bool] = None, ) -> Optional[List[Dict]]: """네이버 일봉 차트 → KIS 형식 dict 리스트 (oldest first). @@ -260,7 +294,11 @@ async def get_naver_daily_chart( return None # 최신 days개만 - return result[-days:] if len(result) > days else result + result = result[-days:] if len(result) > days else result + # 호가단위 의심 봉 표식(값 불변). ETF/벤치마크는 호가단위가 달라 계수에서 제외. + etf = is_non_stock_code_v2(stock_code) if is_etf is None else bool(is_etf) + annotate_naver_bars_v2(result, is_etf=etf) + return result except Exception as exc: warn_throttled(logger, "naver_finance:264", "네이버 차트 조회 실패 %s: %s", stock_code, exc) @@ -270,6 +308,111 @@ async def get_naver_daily_chart( await client.aclose() +# ───────────────────────────────────────────────────────────────────────────── +# 네이버 일봉 종가 신뢰성 검증 (KRX 호가단위 / KRX 종가 대조) +# 운영 DB 실측: 네이버 종가에 KRX 호가단위로 체결 불가능한 값이 섞이고 전일 종가가 KRX와 다르다. +# 원인(NXT 통합 체결가 여부)은 미확인(추정). 값은 바꾸지 않고 의심 봉을 계수·노출만 한다. +# ───────────────────────────────────────────────────────────────────────────── + +_naver_close_stats: Dict[str, int] = { + "checked": 0, "off_tick": 0, "krx_compared": 0, "krx_mismatch": 0, +} + + +def krx_tick_size(price: float) -> int: + """KRX 주식 호가단위(2023-01 통합 기준). ETF/ETN 은 별도(5원 등)라 호출부가 제외해야 한다.""" + if price < 2000: + return 1 + if price < 5000: + return 5 + if price < 20000: + return 10 + if price < 50000: + return 50 + if price < 200000: + return 100 + if price < 500000: + return 500 + return 1000 + + +def is_krx_tick_valid(price: float) -> bool: + """가격이 KRX 주식 호가단위 배수인가. 0 이하/비정상 값은 False.""" + try: + p = int(round(float(price))) + except (TypeError, ValueError): + return False + if p <= 0 or abs(float(price) - p) > 1e-9: + return False + return p % krx_tick_size(p) == 0 + + +def annotate_naver_bars_v2(rows: Optional[List[Dict]], is_etf: bool = False) -> Optional[List[Dict]]: + """각 봉에 종가 호가단위 적합 여부(`krx_tick_ok`)를 붙이고 통계를 누적한다. + + 값(종가 등)은 변경하지 않는다. ETF/ETN 은 호가단위가 달라 검사에서 제외한다. + """ + if not rows or is_etf: + return rows + for r in rows: + try: + ok = is_krx_tick_valid(float(r.get("stck_clpr", 0))) + except (TypeError, ValueError): + ok = False + r["krx_tick_ok"] = ok + _naver_close_stats["checked"] += 1 + if not ok: + _naver_close_stats["off_tick"] += 1 + return rows + + +def compare_naver_with_krx_v2(naver_rows: Optional[List[Dict]], krx_rows: Optional[List[Dict]]) -> Dict[str, int]: + """같은 날짜의 네이버 종가를 KRX 종가(stck_bsop_date/stck_clpr 형식)와 대조한다. + + 불일치 봉에는 `krx_close_mismatch=True` 를 붙이고 통계를 누적한다. 값은 바꾸지 않는다. + """ + krx = {} + for r in krx_rows or []: + try: + krx[str(r["stck_bsop_date"])] = float(r["stck_clpr"]) + except (KeyError, TypeError, ValueError): + continue + compared = mismatch = 0 + for r in naver_rows or []: + k = krx.get(str(r.get("stck_bsop_date"))) + if k is None: + continue + try: + n = float(r.get("stck_clpr", 0)) + except (TypeError, ValueError): + continue + compared += 1 + bad = abs(n - k) > 1e-9 + r["krx_close_mismatch"] = bad + if bad: + mismatch += 1 + _naver_close_stats["krx_compared"] += compared + _naver_close_stats["krx_mismatch"] += mismatch + return {"compared": compared, "mismatch": mismatch} + + +def get_naver_close_quality() -> Dict[str, float]: + """/health 노출용 누적 통계. off_tick_ratio·krx_mismatch_ratio 는 표본 0이면 0.0.""" + st = dict(_naver_close_stats) + st["off_tick_ratio"] = (st["off_tick"] / st["checked"]) if st["checked"] else 0.0 + st["krx_mismatch_ratio"] = (st["krx_mismatch"] / st["krx_compared"]) if st["krx_compared"] else 0.0 + return st + + +def naver_close_quality_alert_v2(max_krx_mismatch_ratio: float = 0.05, min_samples: int = 20) -> Optional[str]: + """KRX 대조 불일치 비율이 임계 초과면 경보 문구, 아니면 None. /health/detail 배선용(main.py 몫).""" + q = get_naver_close_quality() + if q["krx_compared"] >= min_samples and q["krx_mismatch_ratio"] > max_krx_mismatch_ratio: + return (f"네이버-KRX 종가 불일치 {q['krx_mismatch_ratio']:.1%} " + f"({q['krx_mismatch']}/{q['krx_compared']}) > {max_krx_mismatch_ratio:.0%}") + return None + + # ── 세계지수 크롤링 ────────────────────────────────────────────────────────────── # 네이버 세계지수 심볼 매핑 @@ -446,3 +589,29 @@ async def get_stock_fundamentals(stock_code: str) -> Optional[Dict]: warn_throttled(logger, "naver_finance:444", "[FUNDAMENTALS] %s 조회 실패: %s", stock_code, e) _FUNDAMENTALS_CACHE[stock_code] = (_now, None) return None + + +# ── 종목명 단건 조회 (KIS 시세 응답에 종목명이 없을 때의 폴백) ───────────────────── +_STOCK_NAME_CACHE: Dict[str, Tuple[float, str]] = {} +_STOCK_NAME_TTL = 86400.0 # 성공 캐시 24h (종목명은 거의 불변) +_STOCK_NAME_NEG_TTL = 300.0 # 실패 캐시 5분 (장애 시 요청마다 외부 호출 방지) + + +async def get_naver_stock_name(stock_code: str) -> str: + """네이버 m.stock basic API 로 종목명 조회. 실패 시 "" (호출측이 다른 폴백 사용).""" + import time as _t + _now = _t.monotonic() + _c = _STOCK_NAME_CACHE.get(stock_code) + if _c and _now - _c[0] < (_STOCK_NAME_TTL if _c[1] else _STOCK_NAME_NEG_TTL): + return _c[1] + name = "" + try: + url = f"https://m.stock.naver.com/api/stock/{stock_code}/basic" + async with httpx.AsyncClient(verify=True, timeout=2.5, headers=_HEADERS) as client: + resp = await client.get(url) + if resp.status_code == 200: + name = str((resp.json() or {}).get("stockName") or "").strip() + except Exception as e: + warn_throttled(logger, "naver_finance:stock_name", "[NAME] %s 종목명 조회 실패: %s", stock_code, e) + _STOCK_NAME_CACHE[stock_code] = (_now, name) + return name diff --git a/backend/app/services/news_sentiment.py b/backend/app/services/news_sentiment.py index 1c899ca..57ca694 100644 --- a/backend/app/services/news_sentiment.py +++ b/backend/app/services/news_sentiment.py @@ -15,6 +15,7 @@ import asyncio import logging import re +import time from datetime import datetime, timedelta from typing import List, Dict, Any, Optional, Tuple @@ -24,7 +25,7 @@ from bs4 import BeautifulSoup from app.core.config import settings from app.core.redis import setex_json, get_json from app.services.db_worker import db_task_queue -from app.core.timeutil import now_kst +from app.core.timeutil import now_kst, KST from app.core.log_throttle import warn_throttled # noqa: E402 @@ -96,6 +97,86 @@ _HEADERS = { ) } +# 수집 0건이 이 횟수만큼 연속되면 error + 텔레그램 경보 (getattr: settings 에 없으면 기본값) +_EMPTY_ALERT_DEFAULT = 5 +# 0건 결과 단기 캐시(초) — 점수 0.0 을 정상 캐시하지 않고, 재크롤링 폭주만 막는다 +_EMPTY_NEGATIVE_TTL = 300 + +# 수집 건강 상태 (프로세스 내 메모리). /health 등에서 get_news_collect_health() 로 조회. +_health: Dict[str, Dict[str, Any]] = { + "stock": {"consecutive_empty": 0, "last_success_at": None, "alerted": False}, + "market": {"consecutive_empty": 0, "last_success_at": None, "alerted": False}, +} + + +def get_news_collect_health() -> Dict[str, Any]: + """마지막 뉴스 수집 성공 시각과 연속 0건 횟수 (종목/시장 구분).""" + out: Dict[str, Any] = {} + for kind, st in _health.items(): + ts = st["last_success_at"] + out[kind] = { + "consecutive_empty": st["consecutive_empty"], + "last_success_at": ts.isoformat() if ts else None, + } + return out + + +async def _record_collect_result(kind: str, count: int) -> None: + """수집 결과를 기록하고 연속 0건이 임계에 도달하면 error + 텔레그램 경보.""" + st = _health[kind] + if count > 0: + st["consecutive_empty"] = 0 + st["last_success_at"] = now_kst() + st["alerted"] = False + return + st["consecutive_empty"] += 1 + threshold = int(getattr(settings, "NEWS_EMPTY_ALERT_THRESHOLD", _EMPTY_ALERT_DEFAULT)) + if st["consecutive_empty"] < threshold or st["alerted"]: + return + st["alerted"] = True + last = st["last_success_at"].isoformat() if st["last_success_at"] else "없음" + msg = ( + f"[NEWS] {kind} 뉴스 수집 {st['consecutive_empty']}회 연속 0건 - " + f"악재 차단/축소 필터가 동작하지 않음 (마지막 성공: {last})" + ) + logger.error(msg) + try: + from app.services.telegram_notifier import telegram_notifier, _escape_markdown_v2 + await telegram_notifier.send_message(_escape_markdown_v2(msg)) + except Exception as e: + logger.warning(f"[NEWS] 텔레그램 경보 전송 실패: {e}") + + +def _to_kst_naive(dt: datetime) -> datetime: + """aware 값은 KST 로 변환 후 tz 제거, naive 는 KST 로 간주하고 그대로 반환.""" + if dt.tzinfo is not None: + return dt.astimezone(KST).replace(tzinfo=None) + return dt + + +def _parse_iso_to_kst(value: str) -> datetime: + """ISO 문자열('...Z' 포함) → KST naive. 실패 시 ValueError.""" + return _to_kst_naive(datetime.fromisoformat(value.replace("Z", "+00:00"))) + + + +def _parse_naver_json_datetime(raw: Any) -> datetime: + """네이버 모바일 JSON 의 datetime → KST naive. + + 'YYYYMMDDHHMM[SS]' 는 KST 로 간주(오프셋 없음), ISO 문자열은 오프셋을 존중해 KST 로 변환. + 파싱 실패 시 현재 KST. + """ + dt_raw = str(raw or "").strip() + for fmt in ("%Y%m%d%H%M", "%Y%m%d%H%M%S"): + try: + return datetime.strptime(dt_raw, fmt) + except ValueError: + continue + try: + return _parse_iso_to_kst(dt_raw) + except Exception: + return now_kst() + # ═════════════════════════════════════════════════════════════════════════════ # 2. 감성 분석 함수 @@ -183,12 +264,12 @@ def _compute_aggregate_score(articles: List[Dict[str, Any]]) -> float: pub_at = art.get("published_at") if isinstance(pub_at, str): try: - pub_at = datetime.fromisoformat(pub_at.replace("Z", "+00:00")) + pub_at = _parse_iso_to_kst(pub_at) except Exception: pub_at = now if isinstance(pub_at, datetime): - hours_ago = (now - pub_at.replace(tzinfo=None)).total_seconds() / 3600 + hours_ago = (now - _to_kst_naive(pub_at)).total_seconds() / 3600 else: hours_ago = 24 # 파싱 실패 시 기본 24시간 @@ -212,11 +293,59 @@ def _compute_aggregate_score(articles: List[Dict[str, Any]]) -> float: # 3. 뉴스 수집 (네이버 금융 — 종목별) # ═════════════════════════════════════════════════════════════════════════════ +async def _collect_stock_news_json(stock_code: str, limit: int) -> List[Dict[str, Any]]: + """m.stock.naver.com 종목 뉴스 JSON API (1차 소스). 실패/0건이면 빈 리스트. + + 응답 스키마는 외부 호출 금지로 실측하지 못했다(추정): 그룹 리스트 + [{"items":[{"officeId","articleId","officeName","datetime":"YYYYMMDDHHMM","title"}]}] + 또는 items 리스트 자체. 두 형태 모두 방어적으로 파싱한다. + """ + url = f"https://m.stock.naver.com/api/news/stock/{stock_code}?pageSize={limit}&page=1" + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: + resp = await client.get(url, headers=_HEADERS) + if resp.status_code != 200: + logger.warning(f"[NEWS] 네이버 모바일 종목뉴스 HTTP {resp.status_code}: {stock_code}") + return [] + + data = resp.json() + groups = data if isinstance(data, list) else (data.get("items") or data.get("newsList") or []) + raw_items: List[Dict[str, Any]] = [] + for g in groups: + if isinstance(g, dict) and isinstance(g.get("items"), list): + raw_items.extend(i for i in g["items"] if isinstance(i, dict)) + elif isinstance(g, dict): + raw_items.append(g) + + articles: List[Dict[str, Any]] = [] + for it in raw_items: + title = (it.get("title") or "").strip() + if not title: + continue + office, art = it.get("officeId"), it.get("articleId") + link = f"https://n.news.naver.com/mnews/article/{office}/{art}" if office and art else "" + published_at = _parse_naver_json_datetime(it.get("datetime") or it.get("dt")) + score, label = analyze_sentiment_keyword(title) + articles.append({ + "title": title, + "source": it.get("officeName") or "네이버금융", + "url": link, + "stock_codes": [stock_code], + "sentiment": label, + "sentiment_score": score, + "published_at": published_at, + }) + if len(articles) >= limit: + break + return articles + + async def collect_stock_news( stock_code: str, limit: int = 0, ) -> List[Dict[str, Any]]: - """네이버 금융에서 종목별 뉴스를 크롤링하고 감성 분석을 수행한다. + """종목별 뉴스를 수집하고 감성 분석을 수행한다. + + 1차: m.stock.naver.com JSON API, 2차(폴백): finance.naver.com HTML 크롤링. Args: stock_code: 종목 코드 (예: "005930") @@ -228,11 +357,25 @@ async def collect_stock_news( if limit <= 0: limit = settings.NEWS_MAX_ARTICLES + try: + articles = await _collect_stock_news_json(stock_code, limit) + if articles: + return articles + except httpx.TimeoutException: + logger.warning(f"[NEWS] 네이버 모바일 종목뉴스 타임아웃: {stock_code}") + except Exception as e: + logger.warning(f"[NEWS] 네이버 모바일 종목뉴스 오류 ({stock_code}): {e}") + + return await _collect_stock_news_html(stock_code, limit) + + +async def _collect_stock_news_html(stock_code: str, limit: int) -> List[Dict[str, Any]]: + """finance.naver.com HTML 크롤링 (폴백).""" url = f"https://finance.naver.com/item/news_news.naver?code={stock_code}&page=1" articles: List[Dict[str, Any]] = [] try: - async with httpx.AsyncClient(timeout=15.0) as client: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: resp = await client.get(url, headers=_HEADERS) if resp.status_code != 200: @@ -254,10 +397,7 @@ async def collect_stock_news( source_tag = row.select_one(".info") source = source_tag.get_text(strip=True) if source_tag else "네이버금융" - # 날짜 파싱 (YYYY.MM.DD HH:MM 형식) published_at = _parse_naver_date(date_str) - - # 감성 분석 (1단계: 키워드) score, label = analyze_sentiment_keyword(title) articles.append({ @@ -285,8 +425,91 @@ async def collect_stock_news( # 4. 뉴스 수집 (네이버 시장 뉴스 — 코스피/코스닥) # ═════════════════════════════════════════════════════════════════════════════ +# 시장 뉴스 JSON 후보 엔드포인트 (실측 불가 - 추정). 순서대로 시도, 첫 비어있지 않은 결과 사용. +_MARKET_NEWS_JSON_URLS = ( + "https://m.stock.naver.com/api/news/mainnews?pageSize={limit}&page=1", + "https://m.stock.naver.com/api/news/category/domestic?pageSize={limit}&page=1", +) + + +def _extract_news_items(data: Any) -> List[Dict[str, Any]]: + """다양한 JSON 형태(list / {items|newsList|news} / 그룹 중첩)에서 기사 dict 목록 추출.""" + if isinstance(data, dict): + groups = data.get("items") or data.get("newsList") or data.get("news") or [] + else: + groups = data + out: List[Dict[str, Any]] = [] + if not isinstance(groups, list): + return out + for g in groups: + if isinstance(g, dict) and isinstance(g.get("items"), list): + out.extend(i for i in g["items"] if isinstance(i, dict)) + elif isinstance(g, dict): + out.append(g) + return out + + +async def _collect_market_news_json(limit: int) -> List[Dict[str, Any]]: + """m.stock.naver.com 시장(주요) 뉴스 JSON (1차 소스). 실패/0건이면 빈 리스트. + + 스키마·경로는 외부 호출 금지로 실측하지 못했다(추정). 후보 URL 을 순차 시도한다. + """ + articles: List[Dict[str, Any]] = [] + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: + for tmpl in _MARKET_NEWS_JSON_URLS: + url = tmpl.format(limit=limit) + try: + resp = await client.get(url, headers=_HEADERS) + if resp.status_code != 200: + logger.warning(f"[NEWS] 시장뉴스 JSON HTTP {resp.status_code}: {url}") + continue + raw_items = _extract_news_items(resp.json()) + except Exception as e: + logger.warning(f"[NEWS] 시장뉴스 JSON 오류 ({url}): {e}") + continue + for it in raw_items: + title = (it.get("title") or it.get("titleFull") or "").strip() + if not title: + continue + office, art = it.get("officeId"), it.get("articleId") + link = f"https://n.news.naver.com/mnews/article/{office}/{art}" if office and art else "" + score, label = analyze_sentiment_keyword(title) + articles.append({ + "title": title, + "source": it.get("officeName") or "네이버금융", + "url": link, + "stock_codes": [], + "sentiment": label, + "sentiment_score": score, + "published_at": _parse_naver_json_datetime(it.get("datetime") or it.get("dt")), + }) + if len(articles) >= limit: + return articles + if articles: + return articles + return articles + + async def collect_market_news(limit: int = 0) -> List[Dict[str, Any]]: - """네이버 금융 시장 뉴스(코스피/코스닥) 크롤링 + 감성 분석. + """시장 뉴스 수집 + 감성 분석. + + 1차: m.stock.naver.com JSON, 2차(폴백): finance.naver.com HTML 크롤링. + """ + if limit <= 0: + limit = settings.NEWS_MAX_ARTICLES + + try: + articles = await _collect_market_news_json(limit) + if articles: + return articles + except Exception as e: + logger.warning(f"[NEWS] 시장뉴스 JSON 경로 실패, HTML 폴백: {e}") + + return await _collect_market_news_html(limit) + + +async def _collect_market_news_html(limit: int) -> List[Dict[str, Any]]: + """네이버 금융 시장 뉴스(코스피/코스닥) HTML 크롤링 (폴백). Returns: [{"title", "source", "url", "published_at", "sentiment", "sentiment_score"}, ...] @@ -304,7 +527,7 @@ async def collect_market_news(limit: int = 0) -> List[Dict[str, Any]]: if len(articles) >= limit: break try: - async with httpx.AsyncClient(timeout=15.0) as client: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: resp = await client.get(page_url, headers=_HEADERS) if resp.status_code != 200: @@ -393,7 +616,7 @@ async def collect_newsapi_news( pub_str = item.get("publishedAt", "") try: - published_at = datetime.fromisoformat(pub_str.replace("Z", "+00:00")) + published_at = _parse_iso_to_kst(pub_str) except Exception: published_at = now_kst() @@ -438,9 +661,18 @@ async def get_stock_news_score(stock_code: str) -> float: if cached is not None: return float(cached) + # 1-b. 최근 0건 판정(단기 음성 캐시) — 재크롤링 폭주만 막는다. 점수 캐시가 아니다. + if await get_json(f"news_score:stock_empty:{stock_code}") is not None: + return 0.0 + # 2. 실시간 크롤링 try: articles = await collect_stock_news(stock_code) + await _record_collect_result("stock", len(articles)) + if not articles: + # '데이터 없음' 은 중립 점수 0.0 으로 캐시하지 않는다(악재 필터 무력화를 가리므로) + await setex_json(f"news_score:stock_empty:{stock_code}", _EMPTY_NEGATIVE_TTL, 1) + return 0.0 score = _compute_aggregate_score(articles) # 캐시 저장 @@ -470,6 +702,10 @@ async def get_market_news_score() -> float: if cached is not None: return float(cached) + # 최근 0건 판정(단기 음성 캐시) — 재크롤링 폭주만 막는다. 점수 캐시가 아니다. + if await get_json("news_score:market_empty") is not None: + return 0.0 + try: # 시장 뉴스 + NewsAPI 병렬 수집 market_task = collect_market_news() @@ -477,6 +713,10 @@ async def get_market_news_score() -> float: market_news, api_news = await asyncio.gather(market_task, api_task) all_articles = market_news + api_news + await _record_collect_result("market", len(all_articles)) + if not all_articles: + await setex_json("news_score:market_empty", _EMPTY_NEGATIVE_TTL, 1) + return 0.0 # 데이터 없음 — 중립 점수를 캐시하지 않는다 score = _compute_aggregate_score(all_articles) await setex_json(cache_key, settings.NEWS_CACHE_TTL, score) diff --git a/backend/app/services/schedulers.py b/backend/app/services/schedulers.py index 704e71b..5d190b3 100644 --- a/backend/app/services/schedulers.py +++ b/backend/app/services/schedulers.py @@ -96,6 +96,408 @@ async def _save_post_market_last_run(day: _date) -> None: logger.warning(f"[PostMarket] ⚠️ 마지막 실행일 저장 실패: {e}") +# ── [하드닝 R1] 장 마감 후 확정 종가 반영 사이클 ─────────────────────────────── +# 15:30 이후 배치가 곧바로 익일까지 자면 stock_analysis 가 마지막 장중 스냅샷 +# (종가 12~53분 전)으로 남는다. 15:35 이후 1회(실패 시 18:00 까지 재시도) 더 돌려 +# 종가 단일가 매매 이후의 값으로 덮어쓴다. +_BATCH_FINAL_START = (15, 35) +_BATCH_FINAL_END = (18, 0) + + +def _batch_final_cycle_decide_v2(now: _dt, last_final: "_date | None") -> tuple[str, float]: + """15:30 이후 배치 루프의 **순수** 결정 — ("final"|"wait"|"sleep_tomorrow", 초).""" + from app.core.market_schedule import is_krx_holiday + + today = now.date() + if today.weekday() >= 5 or is_krx_holiday(today): + return "sleep_tomorrow", 0.0 + if last_final == today or (now.hour, now.minute) >= _BATCH_FINAL_END: + return "sleep_tomorrow", 0.0 + if (now.hour, now.minute) < _BATCH_FINAL_START: + target = _KST_TZ.localize(_dt(today.year, today.month, today.day, *_BATCH_FINAL_START)) + return "wait", max((target - now).total_seconds(), 15.0) + return "final", 0.0 + + +# ── [하드닝 R1] KRX 일봉 DB 완전성/신선도 점검 ───────────────────────────────── +_KRX_COMPLETE_WINDOW = 60 # 점검 대상 최근 거래일 수(중앙값 산출 + 부분 수집 판정 전체) +_KRX_CHECK_RECENT = 60 # 부분 수집 판정 대상(최근 N 거래일) — 창 전체. 5 로 두면 오래된 부분일이 경보에서 사라졌다 +_KRX_MORNING_LAG = 2 # 아침 점검 기대일: T+1 발행 전이라 '직전 거래일의 전 거래일' +_KRX_RECONCILE_DAYS = 5 # 종가 보정 대상: 최근 N KRX 거래일(멱등) +_KRX_RESCAN_DAYS = 400 # 주 1회 장기 재검사 달력일 범위(collect_krx_data --rescan-days) +_KRX_RESCAN_WEEKDAY = 0 # 월요일 아침 +_KRX_COMPLETE_RATIO = 0.90 # 중앙값 대비 최소 비율 +_KRX_EVENING_GIVEUP = (18, 0) # 이 시각 이후엔 재시도 없이 경보 후 종료 + +_KRX_DATA_HEALTH: dict = { + "checked_at": None, "ok": None, "issues": [], "last_trade_date": None, + "reconcile_unmatched_no_krx": None, # 최근 보정에서 KRX 행이 없어 보정 불가였던 분석 행 수(ETF/ETN 등) +} + + +_KRX_STALE_ALERT_STATE: dict = {"day": None} # 과거 부분일 경보 마지막 발송일 + + +def get_krx_data_health_v2() -> dict: + """/health 노출용 최근 KRX 일봉 점검 결과 사본 (main.py 에서 호출 가능).""" + return dict(_KRX_DATA_HEALTH) + + +def _expected_last_trade_date_v2(today: _date) -> _date: + """today 이전의 가장 최근 KRX 거래일 (data.go.kr T+1 발행이므로 최소 기대치).""" + from app.core.market_schedule import is_krx_holiday + + d = today - _td(days=1) + for _ in range(20): + if d.weekday() < 5 and not is_krx_holiday(d): + return d + d -= _td(days=1) + return d + + +def _expected_trade_date_lag_v2(today: _date, lag: int = 1) -> _date: + """today 이전 lag 번째 KRX 거래일 (lag=1 이면 _expected_last_trade_date_v2 와 동일).""" + d = today + for _ in range(max(1, int(lag))): + d = _expected_last_trade_date_v2(d) + return d + + +_RECONCILE_COLS = ( + "stock_code", "stock_name", "analysis_date", "current_price", "change_rate", "volume", + "rsi", "ma5", "ma20", "ma60", "stochastic_k", "stochastic_d", + "bollinger_upper", "bollinger_lower", "bollinger_middle", + "signals", "score", "ml_score", "ml_confidence", "ml_signal", "atr_14", "adx_14", +) + + +def _build_close_reconcile_rows_v2(analysis_rows: "list[dict]", krx: "dict[str, tuple]") -> "list[dict]": + """stock_analysis 행의 current_price/change_rate/volume 을 KRX 확정 종가로 맞춘 upsert 행 목록 — 순수 함수. + + krx: {종목코드: (close, change_rate(퍼센트 단위)|None, volume|None)}. 종가가 없거나 0 이하이거나 이미 같으면 제외. + 지표(RSI/MA/BB/ATR)는 재계산하지 않는다(종가 1틱 차이 영향 — 한계는 호출부 로그 참고). + """ + out: list[dict] = [] + stamp = now_kst() + for r in analysis_rows: + k = krx.get(r.get("stock_code")) + if not k or k[0] is None or int(k[0]) <= 0: + continue + if int(r.get("current_price") or 0) == int(k[0]): + continue + row = {c: r.get(c) for c in _RECONCILE_COLS} + row["current_price"] = int(k[0]) + if len(k) > 1 and k[1] is not None: + row["change_rate"] = k[1] + if len(k) > 2 and k[2] is not None: + row["volume"] = int(k[2]) + row["updated_at"] = stamp + out.append(row) + return out + + +def _close_reconcile_row_matches_v3(r: dict, k: tuple) -> bool: + """가격·거래량·등락률이 모두 KRX 확정치와 같은가 (KRX 가 값을 주지 않는 항목은 비교 제외).""" + if int(r.get("current_price") or 0) != int(k[0]): + return False + if len(k) > 2 and k[2] is not None and int(r.get("volume") or 0) != int(k[2]): + return False + if len(k) > 1 and k[1] is not None: + cur = r.get("change_rate") + if cur is None or abs(float(cur) - float(k[1])) >= 0.005: + return False + return True + + +def _build_close_reconcile_rows_v3(analysis_rows: "list[dict]", krx: "dict[str, tuple]") -> "list[dict]": + """v2 와 동일하되 '가격이 같으면 건너뜀' 을 '가격·거래량·등락률 모두 같을 때만 건너뜀' 으로 교체. + + v2 는 가격만 우연히 같은 행의 장중 부분 거래량·틀린 등락률을 영구히 남겼다. 가격만 같으면 + volume/change_rate 만 KRX 값으로 갱신한 행이 나온다. 지표는 재계산하지 않는다. + """ + out: list[dict] = [] + stamp = now_kst() + for r in analysis_rows: + k = krx.get(r.get("stock_code")) + if not k or k[0] is None or int(k[0]) <= 0: + continue + if _close_reconcile_row_matches_v3(r, k): + continue + row = {c: r.get(c) for c in _RECONCILE_COLS} + row["current_price"] = int(k[0]) + if len(k) > 1 and k[1] is not None: + row["change_rate"] = k[1] + if len(k) > 2 and k[2] is not None: + row["volume"] = int(k[2]) + row["updated_at"] = stamp + out.append(row) + return out + + +async def _reconcile_prev_analysis_close_v2(today: _date) -> int: + """KRX 아침 수집 이후, 직전 거래일 stock_analysis 종가를 stock_daily_prices(KRX) 로 보정. + + SELECT 전용 + 쓰기는 db_task_queue(bulk_upsert_stock_analysis) 경유. 보정 건수 반환. + 네이버 종가가 KRX 종가와 다른 문제(예: LS 10/7 281,000 vs 280,000)를 T+1 에 치유한다. + """ + from sqlalchemy import select, func + from app.models.models import StockAnalysis, StockDailyPrice + from app.services.db_worker import enqueue_bulk_upsert_analysis + + async with AsyncSessionLocal() as db: # DB-READ-EXCEPTION: SELECT 전용, 배치 스케줄러 + last = (await db.execute( + select(func.max(StockDailyPrice.trade_date)).where(StockDailyPrice.trade_date < today) + )).scalar() + if last is None: + return 0 + px = (await db.execute( + select(StockDailyPrice.stock_code, StockDailyPrice.close_price, + StockDailyPrice.change_rate, StockDailyPrice.volume) + .where(StockDailyPrice.trade_date == last) + )).all() + an = (await db.execute(select(StockAnalysis).where(StockAnalysis.analysis_date == last))).scalars().all() + analysis_rows = [{c: getattr(a, c, None) for c in _RECONCILE_COLS} for a in an] + # stock_daily_prices.change_rate 는 비율(fltRt/100), stock_analysis.change_rate 는 퍼센트 → ×100 변환. + krx = {c: (cl, (round(float(cr) * 100.0, 4) if cr is not None else None), v) for c, cl, cr, v in px} + rows = _build_close_reconcile_rows_v3(analysis_rows, krx) + if rows: + await enqueue_bulk_upsert_analysis(rows) + logger.info(f"[KRX] {last} stock_analysis 종가 KRX 보정: {len(rows)}/{len(analysis_rows)}건 (지표 미재계산)") + return len(rows) + + +async def _reconcile_recent_analysis_close_v3(today: _date, days: int | None = None) -> dict: + """최근 N KRX 거래일(today 미만) stock_analysis 종가를 KRX 확정 종가로 보정 — 멱등. + + v2 는 '최신 1일'만, 그것도 아침 1회만 봐서 T+1 발행 지연·수집 실패일을 영구히 건너뛰었다. + KRX 행이 없는 분석 행(ETF/ETN)은 보정 불가이므로 건수를 집계해 로그/health 에 남긴다. + SELECT 전용 + 쓰기는 db_task_queue 경유. 반환: {"updated","examined","no_krx","dates"}. + """ + from sqlalchemy import select + from app.models.models import StockAnalysis, StockDailyPrice + from app.services.db_worker import enqueue_bulk_upsert_analysis + + n = _KRX_RECONCILE_DAYS if days is None else int(days) + rows: list[dict] = [] + examined = 0 + no_krx = 0 + async with AsyncSessionLocal() as db: # DB-READ-EXCEPTION: SELECT 전용, 배치 스케줄러 + dates = (await db.execute( + select(StockDailyPrice.trade_date).where(StockDailyPrice.trade_date < today) + .distinct().order_by(StockDailyPrice.trade_date.desc()).limit(n) + )).scalars().all() + for d in dates: + px = (await db.execute( + select(StockDailyPrice.stock_code, StockDailyPrice.close_price, + StockDailyPrice.change_rate, StockDailyPrice.volume) + .where(StockDailyPrice.trade_date == d) + )).all() + an = (await db.execute(select(StockAnalysis).where(StockAnalysis.analysis_date == d))).scalars().all() + analysis_rows = [{c: getattr(a, c, None) for c in _RECONCILE_COLS} for a in an] + krx = {c: (cl, (round(float(cr) * 100.0, 4) if cr is not None else None), v) for c, cl, cr, v in px} + no_krx += sum(1 for r in analysis_rows if r.get("stock_code") not in krx) + examined += len(analysis_rows) + rows.extend(_build_close_reconcile_rows_v3(analysis_rows, krx)) + if rows: + await enqueue_bulk_upsert_analysis(rows) + _KRX_DATA_HEALTH["reconcile_unmatched_no_krx"] = no_krx + logger.info( + f"[KRX] 최근 {len(dates)}거래일 stock_analysis 종가 KRX 보정: {len(rows)}/{examined}건 " + f"(KRX 행 없음 {no_krx}건 — ETF/ETN 등 보정 불가, 지표 미재계산)" + ) + return {"updated": len(rows), "examined": examined, "no_krx": no_krx, "dates": len(dates)} + + +def _krx_rescan_due_v2(today: _date, last_rescan: "_date | None") -> bool: + """주 1회(월요일) 장기 부분일 재검사가 필요한가 — 순수 함수.""" + return today.weekday() == _KRX_RESCAN_WEEKDAY and last_rescan != today + + +def _evaluate_krx_completeness_v2( + rows_by_date: "dict[_date, int]", + expected_date: _date, + ratio: float | None = None, + recent: int | None = None, +) -> dict: + """날짜별 행 수로 완전성·신선도를 판정하는 **순수** 함수. + + issues: 최신 거래일이 expected_date 보다 과거 / 최근 N 거래일 중 중앙값×ratio 미만 / + 기간 내 거래일 누락(갭). + """ + from app.core.market_schedule import is_krx_holiday + + ratio = _KRX_COMPLETE_RATIO if ratio is None else ratio + recent = _KRX_CHECK_RECENT if recent is None else recent + issues: list[str] = [] + if not rows_by_date: + return {"ok": False, "issues": ["일봉 데이터 없음"], "last_trade_date": None} + + dates = sorted(rows_by_date) + last = dates[-1] + if last < expected_date: + issues.append(f"최신 거래일 {last} < 기대 {expected_date}") + + counts = sorted(rows_by_date.values()) + median = counts[len(counts) // 2] + floor_n = median * ratio + stale_issues: list[str] = [] # 기대일보다 과거의 부분일/갭 — 재수집 루프로 낫지 않는 경우가 많다 + for d in dates[-recent:]: + if rows_by_date[d] < floor_n: + msg = f"{d} 부분 수집 {rows_by_date[d]}행 < 중앙값 {median}×{ratio:.0%}" + issues.append(msg) + if d < expected_date: + stale_issues.append(msg) + + present = set(dates) + d = dates[0] + while d < last: + d += _td(days=1) + if d.weekday() < 5 and not is_krx_holiday(d) and d not in present: + msg = f"{d} 거래일 누락" + issues.append(msg) + if d < expected_date: + stale_issues.append(msg) + fresh_issues = [i for i in issues if i not in stale_issues] + return { + "ok": not issues, "issues": issues, "last_trade_date": last, "median_rows": median, + "fresh_issues": fresh_issues, "stale_issues": stale_issues, "fresh_ok": not fresh_issues, + } + + +def _krx_behind_days_v2(last: "_date | None", today: _date) -> "int | None": + """last 이후 today 미만의 KRX 거래일 수 (0 이면 직전 거래일까지 적재, 순수 함수).""" + from app.core.market_schedule import is_krx_holiday + + if last is None: + return None + n = 0 + d = last + _td(days=1) + while d < today: + if d.weekday() < 5 and not is_krx_holiday(d): + n += 1 + d += _td(days=1) + return n + + +async def _fetch_krx_row_counts_v2(window: int | None = None) -> "dict[_date, int]": + """stock_daily_prices 최근 N 거래일의 날짜별 행 수 (SELECT 전용).""" + from sqlalchemy import select, func + from app.models.models import StockDailyPrice + + window = _KRX_COMPLETE_WINDOW if window is None else window + async with AsyncSessionLocal() as db: # DB-READ-EXCEPTION: SELECT 전용, 배치 스케줄러 + res = await db.execute( + select(StockDailyPrice.trade_date, func.count(StockDailyPrice.id)) + .group_by(StockDailyPrice.trade_date) + .order_by(StockDailyPrice.trade_date.desc()) + .limit(window) + ) + return {d: int(n) for d, n in res.all()} + + +async def _check_krx_data_v2(today: _date, alert: bool, lag: int = 1, alert_stale: bool = False) -> dict: + """점검 실행 + 상태 기록 + 로그/텔레그램 경보. 점검 자체 실패는 not ok 로 취급. + + lag: 기대 최신일 = today 이전 lag 번째 거래일(아침 점검은 T+1 발행 전이라 2). + """ + try: + counts = await _fetch_krx_row_counts_v2() + result = _evaluate_krx_completeness_v2(counts, _expected_trade_date_lag_v2(today, lag)) + except Exception as e: + logger.warning(f"⚠️ [KRX] 완전성 점검 실패: {e}", exc_info=True) + result = {"ok": False, "issues": [f"점검 실패: {e}"], "last_trade_date": None} + result.setdefault("fresh_issues", list(result["issues"])) + result.setdefault("stale_issues", []) + result.setdefault("fresh_ok", not result["fresh_issues"]) + try: + behind = _krx_behind_days_v2(result.get("last_trade_date"), today) + except Exception as e: + logger.warning(f"[KRX] 거래일 지연 계산 실패: {e}") + behind = None + _KRX_DATA_HEALTH.update( + checked_at=now_kst().isoformat(), ok=result["ok"], issues=list(result["issues"]), + last_trade_date=str(result.get("last_trade_date")) if result.get("last_trade_date") else None, + behind_trading_days=behind, + stale_warning=bool(behind is not None and behind >= 2), + ) + if behind is not None and behind >= 2: + logger.warning(f"⚠️ [KRX] 일봉 DB 가 직전 거래일보다 {behind} 거래일 뒤처짐 (최신 {result.get('last_trade_date')})") + if result["ok"]: + logger.info(f"✅ [KRX] 일봉 완전성 정상 (최신 {result.get('last_trade_date')})") + elif alert or (alert_stale and not result["fresh_issues"]): + # 최신일 지연/부분은 매번, 과거 부분일만 남은 경우는 1일 1회만 (재수집으로 안 낫는 결측의 경보 반복 방지) + fresh_only_stale = not result["fresh_issues"] + if fresh_only_stale and _KRX_STALE_ALERT_STATE.get("day") == today: + logger.warning(f"⚠️ [KRX] 과거 부분일 잔존(오늘 경보 완료): {'; '.join(result['issues'][:3])}") + else: + if fresh_only_stale: + _KRX_STALE_ALERT_STATE["day"] = today + msg = "KRX 일봉 DB 불완전: " + "; ".join(result["issues"][:5]) + logger.error(f"🚨 [KRX] {msg}") + try: + from app.services.telegram_notifier import telegram_notifier + await telegram_notifier.notify_system_warning("KRX_DATA", msg) + except Exception as e: + logger.warning(f"[KRX] 텔레그램 경보 전송 실패: {e}") + else: + logger.warning(f"⚠️ [KRX] 일봉 불완전(재시도 예정): {'; '.join(result['issues'][:5])}") + return result + + +def _krx_collector_decide_v2( + now: _dt, last_collected: "_date | None", last_morning: "_date | None" +) -> tuple[str, float]: + """KRX 수집 루프의 **순수** 결정 — ("morning"|"evening"|"sleep", 초). + + morning: 평일 09:00 이후 1회(T+1 발행 지연 흡수). evening: 15:40 이후. + """ + from app.core.market_schedule import is_krx_holiday + + today = now.date() + if today.weekday() >= 5 or is_krx_holiday(today): + return "sleep", 3600.0 + hm = (now.hour, now.minute) + + def _until(h, m): + return (_KST_TZ.localize(_dt(today.year, today.month, today.day, h, m, 0)) - now).total_seconds() + + if hm < (9, 0): + return "sleep", min(max(_until(9, 0), 15.0), 1800.0) + if hm < (15, 40): + if last_morning != today: + return "morning", 0.0 + return "sleep", min(max(_until(15, 40), 15.0), 1800.0) + if last_collected == today: + nxt = _KST_TZ.localize(_dt(today.year, today.month, today.day, 9, 0, 0)) + _td(days=1) + return "sleep", max((nxt - now).total_seconds(), 60.0) + return "evening", 0.0 + + +_KRX_OFFDAY_COLLECT_HM = (12, 0) # 주말·휴장일 증분 수집 시각(하루 1회) + + +def _krx_collector_decide_v3( + now: _dt, last_collected: "_date | None", last_morning: "_date | None", + last_offday: "_date | None" = None, +) -> tuple[str, float]: + """v2 + 주말·휴장일 12:00 에 하루 1회 "offday" 증분 수집 (연휴 직전 거래일 일봉이 다음 거래일 저녁까지 없던 문제). + + 평일 동작은 v2 와 동일하다. + """ + from app.core.market_schedule import is_krx_holiday + + today = now.date() + if today.weekday() >= 5 or is_krx_holiday(today): + hm = (now.hour, now.minute) + if hm < _KRX_OFFDAY_COLLECT_HM: + tgt = _KST_TZ.localize(_dt(today.year, today.month, today.day, *_KRX_OFFDAY_COLLECT_HM, 0)) + return "sleep", min(max((tgt - now).total_seconds(), 15.0), 3600.0) + if last_offday != today: + return "offday", 0.0 + return "sleep", 3600.0 + return _krx_collector_decide_v2(now, last_collected, last_morning) + + def _normalize_recommendations(rec: dict) -> dict: """서로 다른 포맷의 추천 결과를 recommendations_by_rule 포맷으로 정규화. @@ -146,6 +548,73 @@ def _normalize_recommendations(rec: dict) -> dict: return rec +# ── [하드닝 R2] 배치 분석 헬스/타임아웃/경보 ──────────────────────────────────── +# stock_analysis 는 듀얼 전략·DB 폴백 추천의 유일한 소스(§6.4)라 배치 정지가 '매수 0건' +# 으로만 드러났다. 1회 실행 상한(wait_for) + 최대 재시도 초과 시 텔레그램 + 헬스 노출. +_BATCH_HEALTH: dict = { + "last_ok_at": None, "last_ok_count": None, "last_error": None, + "consecutive_failed_cycles": 0, "last_failed_at": None, +} +_BATCH_ALERT_EVERY_N_CYCLES = 6 # 연속 실패 사이클 중 1, 7, 13... 번째만 경보(스팸 방지) + + +def get_batch_analysis_health_v2() -> dict: + """/health 노출용 배치 분석 상태 사본 (main.py 에서 호출 가능).""" + return dict(_BATCH_HEALTH) + + +def _batch_timeout_sec_v2() -> float: + """배치 1회 실행 상한(초). 설정이 없거나 비정상이면 interval 이상의 안전 기본값.""" + raw = getattr(settings, "BATCH_ANALYSIS_TIMEOUT_SEC", None) + try: + v = float(raw) + if v > 0: + return v + except (TypeError, ValueError): + pass + return 900.0 + + +async def _batch_fetch_v2(fetch, timeout: float | None = None): + """배치 분석 1회 호출에 상한을 건다. 초과 시 TimeoutError(재시도 루프가 처리).""" + t = _batch_timeout_sec_v2() if timeout is None else timeout + return await asyncio.wait_for(fetch(), timeout=t) + + +def _batch_cycle_ok_v2(count: int) -> None: + _BATCH_HEALTH.update( + last_ok_at=now_kst().isoformat(), last_ok_count=count, + last_error=None, consecutive_failed_cycles=0, + ) + try: + from app.core import heartbeat as _hb + _hb.beat("batch_analysis", f"{count} stocks") + except Exception as e: + logger.warning(f"[Batch] heartbeat 기록 실패: {e}") + + +async def _batch_cycle_failed_v2(exc: BaseException, attempts: int) -> None: + """최대 재시도 초과 — 헬스 갱신 + 텔레그램 경보(연속 실패 시 N사이클마다).""" + n = int(_BATCH_HEALTH.get("consecutive_failed_cycles") or 0) + 1 + _BATCH_HEALTH.update( + last_error=f"{type(exc).__name__}: {exc}"[:300], + consecutive_failed_cycles=n, last_failed_at=now_kst().isoformat(), + ) + if (n - 1) % _BATCH_ALERT_EVERY_N_CYCLES != 0: + return + try: + from app.services.telegram_notifier import telegram_notifier + await telegram_notifier.notify_system_warning( + "BATCH_ANALYSIS", + f"배치 분석 {attempts}회 재시도 모두 실패(연속 {n}사이클): " + f"{type(exc).__name__}: {exc}"[:400] + + " — stock_analysis 갱신 정지, 듀얼 전략·DB 폴백 추천 후보 고갈 위험", + ) + except Exception as e: + logger.warning(f"[Batch] 실패 경보 전송 실패: {e}") + + + async def batch_analysis_scheduler(): """10분 간격 배치 분석 스케줄러. @@ -164,37 +633,49 @@ async def batch_analysis_scheduler(): interval = int(getattr(settings, "BATCH_ANALYSIS_INTERVAL", 600)) # 기본 10분 _KST = pytz.timezone("Asia/Seoul") logger.info(f"📊 Batch analysis scheduler started (every {interval}s)") + _last_final: _date | None = None try: while True: _now = pytz.datetime.datetime.now(_KST) - # 15:30 이후 → 장 종료, 익일 08:50까지 대기 + # 15:30 이후 → 장 종료. 15:35 이후 확정 종가 반영 사이클을 1회 돌린 뒤 + # 익일 08:50까지 대기 (하드닝 R1: 장중 스냅샷이 종가로 남던 문제) + _final_pass = False if _now.hour > 15 or (_now.hour == 15 and _now.minute >= 30): - tomorrow_start = ( - _KST.localize( - pytz.datetime.datetime(_now.year, _now.month, _now.day, 8, 50, 0) - ) + _td(days=1) - ) - sleep_sec = max((tomorrow_start - _now).total_seconds(), 3600) - logger.info(f"⏸️ [Batch] 장 종료 (15:30 이후). {sleep_sec / 3600:.1f}시간 후 재개") - await asyncio.sleep(sleep_sec) - continue + _fa, _fsec = _batch_final_cycle_decide_v2(_now, _last_final) + if _fa == "wait": + await asyncio.sleep(_fsec) + continue + if _fa == "final": + _final_pass = True + logger.info("🏁 [Batch] 장 마감 확정 종가 반영 사이클 시작") + else: + tomorrow_start = ( + _KST.localize( + pytz.datetime.datetime(_now.year, _now.month, _now.day, 8, 50, 0) + ) + _td(days=1) + ) + sleep_sec = max((tomorrow_start - _now).total_seconds(), 3600) + logger.info(f"⏸️ [Batch] 장 종료 (15:30 이후). {sleep_sec / 3600:.1f}시간 후 재개") + await asyncio.sleep(sleep_sec) + continue # [FIX 2026-09-23] 개장 전 준비 구간(08:30~09:00)에는 분석을 미리 돌려 # stock_analysis·Redis 추천 캐시를 채워둔다 → 09:00에 추천 종목 즉시 표시. # 그 외 마감 시간에는 개장을 넘겨 자지 않도록 sleep 상한을 둔다. - if not is_market_open() and not is_pre_market_window(): + if not _final_pass and not is_market_open() and not is_pre_market_window(): _until_open = seconds_until_market_open() await asyncio.sleep(1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open))) continue # ── exponential backoff 재시도 (최대 3회) ──────────────────────── max_retries = 3 + _cycle_ok = False for attempt in range(max_retries): try: suffix = f" (재시도 {attempt + 1}/{max_retries})" if attempt > 0 else "" logger.info(f"🔄 [Batch] 10-min analysis cycle starting...{suffix}") - result = await get_rule_based_recommendations_realtime() + result = await _batch_fetch_v2(get_rule_based_recommendations_realtime) count = len(result.get("recommendations", [])) if result else 0 logger.info(f"✅ [Batch] Done: {count} stocks saved/updated in stock_analysis DB") @@ -228,6 +709,8 @@ async def batch_analysis_scheduler(): logger.info(f"💾 [Batch] 개별 종목 분석 데이터 캐시 갱신 완료: {len(cache_tasks)}건 (TTL: {TTL_ANALYSIS}s)") # =============================================== # ─────────────────────────────────────────────────────── + _cycle_ok = True + _batch_cycle_ok_v2(count) break # 성공 시 재시도 루프 탈출 except Exception as e: @@ -240,7 +723,11 @@ async def batch_analysis_scheduler(): await asyncio.sleep(wait) else: logger.error(f"❌ Batch analysis error (최대 재시도 초과): {e}", exc_info=True) + await _batch_cycle_failed_v2(e, max_retries) + if _final_pass and _cycle_ok: + _last_final = _now.date() + logger.info("🏁 [Batch] 확정 종가 반영 사이클 완료") await asyncio.sleep(interval) except asyncio.CancelledError: logger.info("⏹️ Batch analysis scheduler stopped") @@ -430,6 +917,15 @@ async def post_market_analyzer_scheduler(): f"[PostMarket] Telegram 리포트 전송 실패: {_tg_err}", exc_info=True ) + # [하드닝 R4] BUY 건수 + 진입 게이트 차단 Top5 (SELL 만 집계되던 공백) + try: + _buys = await _count_today_buys_v2(db) + _line = _format_funnel_v2(_buys) + logger.info(f"[PostMarket] 진입 퍼널: {_line}") + await _notify_funnel_v2("DAILY_ENTRY_FUNNEL", _line) + except Exception as _fn_err: + logger.warning(f"[PostMarket] 진입 퍼널 집계 실패: {_fn_err}", exc_info=True) + since_7d = now_kst() - _td(days=7) trade_count_result = await db.execute( select(func.count(TradeHistory.id)).where( @@ -470,45 +966,66 @@ async def krx_data_collector_scheduler(): """ _KST = pytz.timezone("Asia/Seoul") _last_collected: _date | None = None + _last_morning: _date | None = None + _last_rescan: _date | None = None + _last_offday: _date | None = None await asyncio.sleep(30) # 부팅 안정화 - logger.info("📅 KRX data collector scheduler started (daily after 15:40 KST)") + logger.info("📅 KRX data collector scheduler started (09:00 보충 + 15:40 이후 수집, 완전성 점검)") try: while True: _now = _dt.now(_KST) today = _now.date() - # 오늘 이미 수집했으면 자정까지 대기 - if _last_collected == today: - # 다음 날 16:00까지 남은 초 계산 - tomorrow_16 = _KST.localize( - _dt(today.year, today.month, today.day, 16, 0, 0) - ) + _td(days=1) - sleep_sec = (tomorrow_16 - _now).total_seconds() - logger.info(f"📅 KRX 오늘 수집 완료. 다음 수집까지 {sleep_sec/3600:.1f}시간 대기") - await asyncio.sleep(max(sleep_sec, 60)) + action, sleep_sec = _krx_collector_decide_v3(_now, _last_collected, _last_morning, _last_offday) + if action == "sleep": + await asyncio.sleep(sleep_sec) continue - # 15:40 이전이면 대기 - collect_hour, collect_min = 15, 40 - if (_now.hour, _now.minute) < (collect_hour, collect_min): - # 오늘 15:40까지 남은 초 - target = _KST.localize( - _dt(today.year, today.month, today.day, collect_hour, collect_min, 0) - ) - sleep_sec = (target - _now).total_seconds() - if sleep_sec > 0: - logger.info(f"📅 KRX 수집 대기 중... 15:40까지 {sleep_sec/60:.0f}분 남음") - await asyncio.sleep(min(sleep_sec, 1800)) # 최대 30분마다 재확인 - continue - - # 주말(토/일)은 스킵하고 다음 날 재확인 - if today.weekday() >= 5: - await asyncio.sleep(3600) + # 주말·휴장일 증분 수집(하루 1회, 실패해도 당일 재시도 안 함) — 경보 없음 + if action == "offday": + _last_offday = today + try: + logger.info("📥 [KRX] 휴장일 증분 수집 시작 (직전 거래일 일봉 선적재)") + from collect_krx_data import collect_all + await collect_all(update_mode=True) + except Exception as e: + logger.warning(f"⚠️ [KRX] 휴장일 증분 수집 실패: {e}", exc_info=True) + try: + await _reconcile_recent_analysis_close_v3(today) + except Exception as e: + logger.warning(f"⚠️ [KRX] 휴장일 종가 보정 실패: {e}", exc_info=True) + await _check_krx_data_v2(today, alert=False, lag=_KRX_MORNING_LAG) continue - # 수집 실행 (최대 3회 재시도, 30분 간격) + # 아침 보충(data.go.kr T+1 발행 지연 흡수): 1회, 실패해도 당일 재시도 안 함 + if action == "morning": + _last_morning = today + try: + logger.info("📥 [KRX] 아침 보충 수집 시작 (T+1 발행 지연 흡수)") + from collect_krx_data import collect_all + await collect_all(update_mode=True) + except Exception as e: + logger.error(f"❌ [KRX] 아침 보충 수집 실패: {e}", exc_info=True) + try: + await _reconcile_recent_analysis_close_v3(today) + except Exception as e: + logger.warning(f"⚠️ [KRX] 직전일 종가 보정 실패: {e}", exc_info=True) + # 주 1회 장기 재검사: 21일 창 밖 부분 수집일 치유 + if _krx_rescan_due_v2(today, _last_rescan): + _last_rescan = today + try: + logger.info(f"📥 [KRX] 주간 장기 재검사 시작 (rescan {_KRX_RESCAN_DAYS}일)") + from collect_krx_data import collect_all_v2 + await collect_all_v2(update_mode=True, rescan_days=_KRX_RESCAN_DAYS) + except Exception as e: + logger.error(f"❌ [KRX] 주간 장기 재검사 실패: {e}", exc_info=True) + # 아침은 T+1 발행 전일 수 있어 기대일을 한 거래일 더 늦춘다(거짓 경보 방지) + await _check_krx_data_v2(today, alert=True, lag=_KRX_MORNING_LAG) + continue + + # 저녁 수집 (최대 3회 재시도, 30분 간격) max_krx_retries = 3 for krx_attempt in range(max_krx_retries): try: @@ -516,8 +1033,20 @@ async def krx_data_collector_scheduler(): logger.info(f"📥 [KRX] 일봉 데이터 증분 수집 시작 (update mode){suffix}...") from collect_krx_data import collect_all await collect_all(update_mode=True) - _last_collected = today - logger.info("✅ [KRX] 일봉 데이터 수집 완료") + logger.info("✅ [KRX] 일봉 데이터 수집 호출 완료 — 완전성 점검") + # 불완전이면 18:00 까지 30분 간격 재시도, 그 후엔 경보하고 종료(아침 보충이 이어받음) + _giveup = (_dt.now(_KST).hour, _dt.now(_KST).minute) >= _KRX_EVENING_GIVEUP + res = await _check_krx_data_v2(today, alert=_giveup, alert_stale=True) + # 보정은 완전성 판정과 분리: 멱등이고 KRX 행이 있는 종목만 대상이라 항상 실행 + try: + await _reconcile_recent_analysis_close_v3(today) + except Exception as e: + logger.warning(f"⚠️ [KRX] 저녁 종가 보정 실패: {e}", exc_info=True) + # 재수집 루프는 최신일 문제(fresh)에만 — 과거 부분일은 재수집으로 안 낫는 경우가 많다 + if res.get("fresh_ok", res["ok"]) or _giveup: + _last_collected = today + else: + await asyncio.sleep(1800) break except Exception as e: if krx_attempt < max_krx_retries - 1: @@ -528,12 +1057,153 @@ async def krx_data_collector_scheduler(): await asyncio.sleep(1800) else: logger.error(f"❌ [KRX] 데이터 수집 최대 재시도 초과: {e}", exc_info=True) + _last_collected = today except asyncio.CancelledError: logger.info("⏹️ KRX data collector scheduler stopped") raise +# ── [하드닝 R4] '매수 0건' 조기 탐지: 진입 게이트 차단 집계 + 0건 경보 ──────────── +# 수개월 매수 0건 사고(ML HOLD 게이트·빈 추천 캐시)가 일 단위 이상 늦게 발견됐다. +# - 진입 경로는 record_entry_gate_block_v2("mdd") 처럼 차단 사유를 사이클마다 호출(인메모리, DB 없음). +# - zero_buy_watchdog_scheduler 가 장중 BULL 인데 BUY 0건이 N시간 이어지면 텔레그램 경보. +# - 15:40 일일 리포트에 BUY 건수와 차단 Top5 를 덧붙인다. +_ENTRY_FUNNEL: dict = {"day": None, "blocks": {}, "attempts": 0, "last_alert_day": None} +_ENTRY_FUNNEL_MAX_GATES = 50 +_BUY_COUNT_STATUSES = ("COMPLETED", "FILLED", "PENDING") + + +def _funnel_roll_v2(today: "_date") -> None: + """날짜가 바뀌면 일일 집계를 리셋한다(경보 발송 이력은 유지).""" + if _ENTRY_FUNNEL["day"] != today: + _ENTRY_FUNNEL["day"] = today + _ENTRY_FUNNEL["blocks"] = {} + _ENTRY_FUNNEL["attempts"] = 0 + + +def record_entry_gate_block_v2(gate: str, n: int = 1) -> None: + """진입 게이트 차단 1회 기록. 예외를 던지지 않는다(호출부 매매 흐름 보호).""" + try: + _funnel_roll_v2(now_kst().date()) + blocks = _ENTRY_FUNNEL["blocks"] + key = str(gate)[:40] + if key not in blocks and len(blocks) >= _ENTRY_FUNNEL_MAX_GATES: + key = "other" + blocks[key] = blocks.get(key, 0) + max(0, int(n)) + except Exception as e: + logger.warning(f"[Funnel] 게이트 차단 기록 실패: {e}") + + +def record_entry_attempt_v2(n: int = 1) -> None: + """진입 시도(게이트 통과 후 주문 시도) 1회 기록.""" + try: + _funnel_roll_v2(now_kst().date()) + _ENTRY_FUNNEL["attempts"] += max(0, int(n)) + except Exception as e: + logger.warning(f"[Funnel] 진입 시도 기록 실패: {e}") + + +def get_entry_funnel_v2(top: int = 5) -> dict: + """/health·리포트 노출용. 오늘 집계가 아니면 빈 집계.""" + today = now_kst().date() + if _ENTRY_FUNNEL["day"] != today: + return {"day": str(today), "attempts": 0, "blocks_top": []} + ranked = sorted(_ENTRY_FUNNEL["blocks"].items(), key=lambda kv: kv[1], reverse=True) + return {"day": str(today), "attempts": _ENTRY_FUNNEL["attempts"], + "blocks_top": ranked[: max(1, top)]} + + +def _zero_buy_alert_hours_v2() -> float: + raw = getattr(settings, "ZERO_BUY_ALERT_HOURS", None) + try: + v = float(raw) + if v > 0: + return v + except (TypeError, ValueError): + pass + return 3.0 + + +def _zero_buy_decide_v2(now: _dt, buys_today: int, regime_200d: str | None, + last_alert_day: "_date | None", hours: float) -> bool: + """순수 판정: 평일 장중, 개장 후 hours 경과, BULL, 오늘 BUY 0건, 오늘 미경보면 True.""" + if now.weekday() >= 5 or buys_today > 0 or regime_200d != "bull": + return False + if last_alert_day == now.date(): + return False + open_dt = now.replace(hour=9, minute=0, second=0, microsecond=0) + if now < open_dt + _td(hours=hours): + return False + close_dt = now.replace(hour=15, minute=20, second=0, microsecond=0) + return now <= close_dt + + +async def _count_today_buys_v2(db) -> "dict[str, int]": + """오늘 BUY 건수(전략별). SELECT 전용.""" + from app.models.models import TradeHistory + from sqlalchemy import select, func, and_ + start = now_kst().replace(hour=0, minute=0, second=0, microsecond=0) + res = await db.execute( + select(TradeHistory.strategy_type, func.count(TradeHistory.id)).where( + and_( + TradeHistory.order_type == "BUY", + TradeHistory.status.in_(list(_BUY_COUNT_STATUSES)), + TradeHistory.created_at >= start, + ) + ).group_by(TradeHistory.strategy_type) + ) + return {str(k or "swing"): int(c) for k, c in res.all()} + + +def _format_funnel_v2(buys: "dict[str, int]") -> str: + f = get_entry_funnel_v2(5) + total = sum(buys.values()) + by = ", ".join(f"{k}:{v}" for k, v in sorted(buys.items())) or "-" + top = ", ".join(f"{g}x{c}" for g, c in f["blocks_top"]) or "집계 없음" + return f"BUY {total}건({by}) / 진입시도 {f['attempts']} / 차단Top5: {top}" + + +async def _notify_funnel_v2(category: str, text: str) -> None: + try: + from app.services.telegram_notifier import telegram_notifier + await telegram_notifier.notify_system_warning(category, text) + except Exception as e: + logger.warning(f"[Funnel] 텔레그램 전송 실패: {e}") + + +async def zero_buy_watchdog_scheduler(): + """장중 BULL 인데 BUY 0건이 이어지면 경보. main.py lifespan 에서 create_task 로 등록한다.""" + await asyncio.sleep(30) + logger.info("🔎 Zero-buy watchdog started") + try: + while True: + try: + now = now_kst() + if is_market_open(): + from app.services.market_analyzer import check_market_regime + regime = (await check_market_regime() or {}).get("regime_200d") + # DB-READ-EXCEPTION: SELECT 전용(commit/add 없음), 5분 주기 감시 + async with AsyncSessionLocal() as db: + buys = await _count_today_buys_v2(db) + if _zero_buy_decide_v2(now, sum(buys.values()), regime, + _ENTRY_FUNNEL["last_alert_day"], + _zero_buy_alert_hours_v2()): + _ENTRY_FUNNEL["last_alert_day"] = now.date() + msg = (f"BULL 국면인데 오늘 BUY 0건 ({_zero_buy_alert_hours_v2():g}시간 경과). " + + _format_funnel_v2(buys)) + logger.error(f"[Funnel] 🚨 {msg}") + await _notify_funnel_v2("ZERO_BUY", msg) + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"[Funnel] watchdog 사이클 실패: {e}", exc_info=True) + await asyncio.sleep(300) + except asyncio.CancelledError: + logger.info("⏹️ Zero-buy watchdog stopped") + raise + + # Deprecated schedulers (kept for backwards compatibility) async def auto_trade_scheduler(): diff --git a/backend/app/services/sector_mapper.py b/backend/app/services/sector_mapper.py index 84197c8..5ca8ffd 100644 --- a/backend/app/services/sector_mapper.py +++ b/backend/app/services/sector_mapper.py @@ -513,3 +513,107 @@ def _write_cache_file(payload: Dict) -> None: with open(tmp, "w", encoding="utf-8") as fp: fp.write(_dumps(payload)) os.replace(tmp, path) + + +# ─ 캐시 건강도 · 자동 갱신 (R3: 운영에 sector_map.json 이 없어 섹터캡이 15.7% 에만 적용) ── +# 설계: 조회 경로(get_sector)는 그대로 네트워크 금지. 아래 스케줄러만 네트워크를 쓴다. +# main.py/schedulers.py 배선은 호출부 소유 그룹이 `sector_map_refresh_scheduler()` 를 +# asyncio.create_task 로 띄우고 /health 에 `get_cache_health_v2()` 를 노출한다. +SECTOR_CACHE_MAX_AGE_DAYS = 7 +SECTOR_REFRESH_HOUR_KST = 8 +SECTOR_REFRESH_MINUTE_KST = 30 +_SECTOR_REFRESH_RETRY_SEC = 1800.0 # 실패 시 재시도 간격(30분) + + +def get_cache_health_v2(max_age_days: int = SECTOR_CACHE_MAX_AGE_DAYS) -> Dict: + """캐시 파일 존재·나이 점검 (동기, 파일 stat/파싱만 — 네트워크·DB 없음). + + Returns: + {"ok":bool,"exists":bool,"stale":bool,"age_days":float|None,"updated_at":str|None, + "stocks":int,"cache_path":str,"reason":str|None} + """ + path = get_cache_path() + payload = load_cache(force=True) + result: Dict = { + "ok": False, "exists": False, "stale": True, "age_days": None, + "updated_at": None, "stocks": len(_cache_sectors), "cache_path": str(path), + "reason": None, + } + try: + mtime = path.stat().st_mtime + except OSError: + result["reason"] = "missing" + return result + result["exists"] = True + result["updated_at"] = payload.get("updated_at") + age_ref = mtime + updated_at = payload.get("updated_at") + if updated_at: + try: + age_ref = datetime.fromisoformat(str(updated_at)).timestamp() + except ValueError: + pass # 파싱 불가 시 파일 mtime 기준 + age_days = max(0.0, (time.time() - age_ref) / 86400.0) + result["age_days"] = round(age_days, 2) + if not _cache_sectors: + result["reason"] = "empty_or_unreadable" + return result + if age_days > max_age_days: + result["reason"] = "stale" + return result + result.update({"ok": True, "stale": False}) + return result + + +def log_cache_health_v2() -> Dict: + """기동 시 호출: 캐시가 없거나 낡았으면 error 로그 (섹터캡 무력화 식별용).""" + health = get_cache_health_v2() + if not health["ok"]: + logger.error( + "[SECTOR_MAP] 섹터 캐시 %s (path=%s, age_days=%s) — 큐레이션 44종목 외에는 §5.1 섹터캡 미적용", + health["reason"], health["cache_path"], health["age_days"], + ) + return health + + +def _next_refresh_delay_v2(now: Optional[datetime] = None) -> float: + """다음 평일 08:30 KST 까지 남은 초.""" + from datetime import timedelta + from zoneinfo import ZoneInfo + kst = ZoneInfo("Asia/Seoul") + cur = (now or datetime.now(kst)).astimezone(kst) + target = cur.replace(hour=SECTOR_REFRESH_HOUR_KST, minute=SECTOR_REFRESH_MINUTE_KST, + second=0, microsecond=0) + if target <= cur: + target += timedelta(days=1) + while target.weekday() >= 5: + target += timedelta(days=1) + return max(1.0, (target - cur).total_seconds()) + + +async def sector_map_refresh_scheduler() -> None: + """기동 시 캐시 없음/낡음이면 즉시 갱신, 이후 평일 08:30 KST 마다 갱신. + + 실패해도 기존 캐시를 보존(refresh_sector_map 계약)하며 30분 후 재시도한다. + 네트워크는 이 태스크에서만 사용한다(실시간 경로 아님, DB 접근 없음). + """ + health = log_cache_health_v2() + due_now = not health["ok"] + while True: + try: + if not due_now: + await asyncio.sleep(_next_refresh_delay_v2()) + result = await refresh_sector_map() + if result.get("ok"): + due_now = False + else: + logger.error("[SECTOR_MAP] 자동 갱신 실패: %s — %d초 후 재시도", + result.get("reason"), int(_SECTOR_REFRESH_RETRY_SEC)) + await asyncio.sleep(_SECTOR_REFRESH_RETRY_SEC) + due_now = True + except asyncio.CancelledError: + raise + except Exception as exc: + logger.error("[SECTOR_MAP] 갱신 스케줄러 예외: %s", exc) + await asyncio.sleep(_SECTOR_REFRESH_RETRY_SEC) + due_now = True diff --git a/backend/app/services/strategy_config_service.py b/backend/app/services/strategy_config_service.py index 8235407..f64ee55 100644 --- a/backend/app/services/strategy_config_service.py +++ b/backend/app/services/strategy_config_service.py @@ -108,7 +108,7 @@ DEFAULT_PARAMS: Dict[str, tuple] = { "DAY_TRADE_CAPITAL_RATIO": ("DAY_TRADE", "전체 자본 비율 상한", "데이트레이딩에 투입 가능한 총자본 비율", "float", "0.30", "0.05","1.0"), # [2026-09-27] config.py 정합: 기본값 0.10 → 0.06 (min 0.01 이므로 범위 조정 불필요) "DAY_TRADE_PER_STOCK_RATIO": ("DAY_TRADE", "종목당 자본 비율", "1종목당 최대 투입 자본 비율", "float", "0.06", "0.01","0.5"), - "DAY_TRADE_STOP_LOSS_PCT": ("DAY_TRADE", "데이트레이딩 손절 비율", "종목 손절선 (%)", "float", "0.02", "0.005","0.1"), + "DAY_TRADE_STOP_LOSS_PCT": ("DAY_TRADE", "복원 폴백 손절", "재시작 복원 폴백 손절선 (실제 진입 손절은 전략별 ORB/REVERSION 손절)", "float", "0.02", "0.005","0.1"), "DAY_TRADE_DAILY_LOSS_LIMIT": ("DAY_TRADE", "일일 손실 한도", "일일 손실 한도 초과 시 데이트레이딩 중단 (%)", "float", "-0.03", "-0.2", "-0.005"), "DAY_TRADE_EOD_FLAT_HOUR": ("DAY_TRADE", "EOD 강제 청산 시 (시)", "장 마감 전 강제 청산 시작 시각 (시)", "int", "15", "13", "15"), "DAY_TRADE_EOD_FLAT_MIN": ("DAY_TRADE", "EOD 강제 청산 시 (분)", "장 마감 전 강제 청산 시작 시각 (분)", "int", "20", "0", "59"), @@ -144,16 +144,102 @@ DEFAULT_PARAMS: Dict[str, tuple] = { "SHORT_TERM_VOLUME_MULTIPLIER": ("GENERAL", "단기 전략 거래량 배수", "단기 전략 진입 조건: 거래량 배수", "float", "1.3", "1.0", "5.0"), } +# [2026-10-09] DAY_TRADE 그룹은 **settings(config.py) 단일 소스**다. DT 엔진 +# (market_schedule._dt_eod_flat, day_trading_strategy, daytrading/*)은 strategy_cfg 를 읽지 않으므로 +# UI 에서 값을 바꿔도 아무 효과가 없었다(예: RSI_BREAKEVEN 40 표시 vs 실제 RSI_BB 게이트 35). +# 효과 없는 편집이 "적용된 것처럼" 보이는 것을 막기 위해 쓰기·캐시 노출·시드를 차단한다. +# 배선하려면 §0 우선순위 결정과 백테스트 근거가 필요하다. +READ_ONLY_GROUPS = frozenset({"DAY_TRADE"}) +# [2026-10-09 R5] RISK 그룹 중 실매매 경로가 strategy_cfg 를 읽지 않는 키(settings 단일 소스). +# UI 에서 낮춰도(예: MAX_POSITION_SIZE 0.04→0.02) 실매매는 settings 값을 쓰므로 "위험을 줄였다"는 거짓 믿음을 만든다. +# 실매매에 배선된 RISK 키(STOP_LOSS_*, ATR_*_MULTIPLIER)와 MDD_*(swing/scheduler 가 불일치 경보)는 제외한다. +# 배선하려면 §0 우선순위 결정이 필요하며 MAX_POSITION_SIZE 는 min(strategy_cfg, settings) 로만 허용해야 한다. +# 백테스트/최적화(strategy_cfg.get)는 이 키들에 대해 settings 폴백을 받는다(실전과 동일 값). +READ_ONLY_RISK_KEYS = frozenset({ + "MAX_POSITION_SIZE", "MAX_DAILY_TRADES", "MAX_HOLDINGS_COUNT", + "MIN_PRICE_KRW", "MIN_PRICE_USD", + "CONSECUTIVE_LOSS_BAN_COUNT", "CONSECUTIVE_LOSS_BAN_DAYS", + "MEAN_REVERSION_MAX_CAPITAL", "TAKE_PROFIT_PERCENT", +}) +READ_ONLY_KEYS = frozenset( + {k for k, v in DEFAULT_PARAMS.items() if v[0] in READ_ONLY_GROUPS} | READ_ONLY_RISK_KEYS +) + + +# [2026-10-09] 표시 키 -> 엔진이 실제로 읽는 settings 키. 별칭 키는 소비자가 없어 +# settings 값을 그대로 보여주면 엔진 실효값과 어긋난다(RSI_BB 40 표시 vs 게이트 35). +DT_EFFECTIVE_KEY_ALIASES: Dict[str, str] = { + "DAY_TRADE_RSI_BREAKEVEN_THRESHOLD": "DAY_TRADE_RSI_BB_RSI_THRESHOLD", + "DAY_TRADE_EOD_FLAT_HOUR": "DAY_TRADE_EOD_HOUR", + "DAY_TRADE_EOD_FLAT_MIN": "DAY_TRADE_EOD_MIN", + "DAY_TRADE_PER_STOCK_RATIO": "DAY_TRADE_MAX_CAPITAL_RATIO", + "DAY_TRADE_CAPITAL_RATIO": "DAY_TRADE_MAX_RATIO_DT", + "DAY_TRADE_ORB_VOL_MULT": "DAY_TRADE_ORB_BREAKOUT_VOL_MULT", +} +# DB 행 라벨은 시드 후 갱신되지 않으므로 직렬화 시 덮어쓴다. +DT_LABEL_OVERRIDES: Dict[str, str] = { + "DAY_TRADE_STOP_LOSS_PCT": "복원 폴백 손절", +} + + +def _effective_settings_key_v2(key: str) -> str: + return DT_EFFECTIVE_KEY_ALIASES.get(key, key) + + +def is_read_only_key(key: str, group: Optional[str] = None) -> bool: + return key in READ_ONLY_KEYS or (group in READ_ONLY_GROUPS) or key.startswith("DAY_TRADE_") # READ_ONLY_KEYS 에 RISK 미배선 키 포함 + + +def get_effective_dt_params_v2() -> Dict[str, Any]: + """DT 엔진이 실제로 쓰는 값(settings). UI 표시용 — strategy_configs 값이 아니다.""" + from app.core.config import settings + out: Dict[str, Any] = {} + for k in sorted(READ_ONLY_KEYS): + ek = _effective_settings_key_v2(k) + if hasattr(settings, ek): + out[k] = getattr(settings, ek) + return out + + +def serialize_config_v2(cfg: Any) -> Dict[str, Any]: + """API 응답용 직렬화. read_only 행은 DB 값 대신 settings 실효값을 표시한다(거짓 표시 방지).""" + from app.core.config import settings + d = { + "id": cfg.id, "key": cfg.key, "group": cfg.group, "label": cfg.label, + "description": cfg.description, "value_type": cfg.value_type, + "value": cfg.value, "default_value": cfg.default_value, + "min_value": cfg.min_value, "max_value": cfg.max_value, + "is_active": cfg.is_active, "updated_by": cfg.updated_by, + "read_only": False, + } + if is_read_only_key(cfg.key, cfg.group): + d["read_only"] = True + ek = _effective_settings_key_v2(cfg.key) + if cfg.key in DT_LABEL_OVERRIDES: + d["label"] = DT_LABEL_OVERRIDES[cfg.key] + if hasattr(settings, ek): + eff = str(getattr(settings, ek)) + d["value"] = eff + d["default_value"] = eff + d["updated_by"] = "settings" + return d + class StrategyConfigService: """전략 파라미터 DB 캐시 서비스.""" CACHE_TTL = 30 # seconds + STALE_ERROR_SEC = 300 # 갱신 실패 지속 시 error 로그/health 경보 기준 def __init__(self): self._cache: Dict[str, Any] = {} self._cache_ts: float = 0.0 self._initialized = False + # 동시 갱신 1건만 허용(in-flight 가드) + 태스크 참조 보관(GC 방지) + self._refresh_inflight: bool = False + self._refresh_task: Optional["asyncio.Future"] = None + self._last_success_ts: float = 0.0 # monotonic, 마지막 성공 시각 + self._consecutive_failures: int = 0 # ── public API ──────────────────────────────────────────────────────────── @@ -165,7 +251,13 @@ class StrategyConfigService: try: loop = asyncio.get_event_loop() if loop.is_running(): - asyncio.ensure_future(self._refresh()) + if not self._refresh_inflight: + self._refresh_inflight = True + try: + self._refresh_task = asyncio.ensure_future(self._refresh()) + except Exception: + self._refresh_inflight = False + raise else: loop.run_until_complete(self._refresh()) except Exception as _swallowed: @@ -189,6 +281,8 @@ class StrategyConfigService: try: async with AsyncSessionLocal() as session: for key, (group, label, desc, vtype, default, vmin, vmax) in DEFAULT_PARAMS.items(): + if is_read_only_key(key, group): + continue # DT 는 settings 단일 소스 — 무효 행을 새로 시드하지 않는다 stmt = pg_insert(StrategyConfig).values( key=key, group=group, @@ -219,6 +313,8 @@ class StrategyConfigService: async def upsert(self, key: str, value: str, updated_by: str = "api") -> Optional[StrategyConfig]: """파라미터 값을 업데이트하고 캐시를 무효화.""" + if is_read_only_key(key): + raise ValueError(f"{key}: DAY_TRADE 파라미터는 config.py(settings) 단일 소스이며 UI 로 변경할 수 없습니다") try: async with AsyncSessionLocal() as session: result = await session.execute( @@ -252,6 +348,8 @@ class StrategyConfigService: async def reset_to_default(self, key: str) -> Optional[StrategyConfig]: """파라미터를 기본값으로 리셋.""" + if is_read_only_key(key): + raise ValueError(f"{key}: DAY_TRADE 파라미터는 settings 단일 소스라 리셋 대상이 아닙니다") async with AsyncSessionLocal() as session: result = await session.execute( select(StrategyConfig).where(StrategyConfig.key == key) @@ -280,9 +378,27 @@ class StrategyConfigService: def _is_cache_stale(self) -> bool: return (time.monotonic() - self._cache_ts) > self.CACHE_TTL + def get_cache_health_v2(self) -> Dict[str, Any]: + """/health 노출용 캐시 상태. 마지막 성공 후 STALE_ERROR_SEC 초과면 stale_alarm=True.""" + age = (time.monotonic() - self._last_success_ts) if self._last_success_ts else None + return { + "age_sec": None if age is None else round(age, 1), + "consecutive_failures": self._consecutive_failures, + "stale_alarm": age is None or age > self.STALE_ERROR_SEC, + "keys": len(self._cache), + } + async def _refresh(self): - """DB에서 전체 파라미터를 읽어 캐시 갱신.""" + """DB에서 전체 파라미터를 읽어 캐시 갱신. 실패 시 _cache_ts 를 밀어 TTL 만큼 백오프.""" + self._refresh_inflight = True try: + await self._refresh_impl() + finally: + self._refresh_inflight = False + + async def _refresh_impl(self): + try: + # DB-READ-EXCEPTION: SELECT 전용(쓰기 없음), in-flight 1건 + TTL 백오프로 호출 폭주 차단 async with AsyncSessionLocal() as session: result = await session.execute( select(StrategyConfig).where(StrategyConfig.is_active == True) @@ -291,6 +407,8 @@ class StrategyConfigService: new_cache: Dict[str, Any] = {} for row in rows: + if is_read_only_key(row.key, row.group): + continue # settings 단일 소스 — strategy_cfg.get 으로 새어 나가지 않게 한다 try: new_cache[row.key] = self._cast(row.value, row.value_type) except Exception: @@ -299,8 +417,17 @@ class StrategyConfigService: self._cache = new_cache self._cache_ts = time.monotonic() + self._last_success_ts = self._cache_ts + self._consecutive_failures = 0 except Exception as e: - logger.warning(f"[StrategyConfig] 캐시 갱신 실패 (기존 캐시 유지): {e}") + self._consecutive_failures += 1 + # 실패해도 _cache_ts 를 현재로 밀어 다음 시도를 CACHE_TTL 뒤로 미룬다(백오프) + self._cache_ts = time.monotonic() + logger.warning(f"[StrategyConfig] 캐시 갱신 실패 (기존 캐시 유지, 연속 {self._consecutive_failures}회): {e}") + if self._last_success_ts: + age = time.monotonic() - self._last_success_ts + if age > self.STALE_ERROR_SEC: + logger.error(f"[StrategyConfig] 전략 파라미터 캐시가 {age:.0f}초째 낡음 — 옛 값으로 매매 중") @staticmethod def _cast(value: str, value_type: str) -> Any: diff --git a/backend/app/services/swing/entry_service.py b/backend/app/services/swing/entry_service.py index 9f280e1..7c11510 100644 --- a/backend/app/services/swing/entry_service.py +++ b/backend/app/services/swing/entry_service.py @@ -24,6 +24,7 @@ strategy_type='swing' 진입만 처리하는 독립 서비스. """ import asyncio import logging +import math from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from datetime import date @@ -252,6 +253,129 @@ def _inject_strategy_type(rec: dict, strategy_type: str) -> None: stock["strategy_type"] = strategy_type +def _below_min_price_krw_v2(price) -> bool: + """국내 가격 필터 — True 면 진입 거부 (CLAUDE.md §5.1 '국내 >5,000원' = 초과만 허용). + + [하드닝 R2 2026-10-09] 듀얼 스캔은 `<`(정확히 MIN_PRICE_KRW 허용), 박스권은 `<=`(거부)로 + 경로마다 경계가 달랐다. 진입 단계(trade_executor·manual_buy·portfolio_v2)는 `<=` 이므로 + 후보 단계에서 통과한 5,000원 종목이 뒤에서 조용히 탈락해 평가 슬롯을 소모했다. + 해석 불가(None·문자열·NaN·inf)도 거부한다(fail-closed). + """ + try: + p = float(price if price is not None else 0) + except (TypeError, ValueError): + return True + if not math.isfinite(p): + return True + return p <= float(settings.MIN_PRICE_KRW) + + +# ─── 듀얼·박스권 스캔용 일봉 (하드닝 R2 2026-10-09) ───────────────────────── +# KIS get_daily_chart(FHKST01010400 inquire-daily-price)는 limit 과 무관하게 30행만 돌려준다 +# (로컬 로그 'Fetched N chart data points' 전부 N=30). 눌림목 v2 는 60봉(MA60), 박스권은 40봉을 +# 요구하므로 두 스캔은 구조적으로 한 번도 발화하지 못했다. 네이버 일봉(market_analyzer· +# adaptive 와 같은 1차 소스) → KIS 기간 조회(FHKST03010100, 날짜 소급 페이지네이션) → +# 기존 get_daily_chart 순으로 확보한다. +DUAL_SCAN_MIN_BARS = 60 # check_pullback_buy_signal_v2: len(closes) < 60 이면 None +RANGE_SCAN_MIN_BARS = 40 # scan_range_trading_signals 기존 하한 +# get_naver_daily_chart(days=N) 은 캘린더 (N+30)일 창 — 120 → 150일 ≈ 100 거래일 +# (market_analyzer 실측: 80→75~77봉, 200→약 155봉). +_SCAN_NAVER_CHART_DAYS = 120 +_SCAN_KIS_RANGE_CALENDAR_DAYS = 150 + + +def _bars_end_today_v2(bars_oldest_first: list) -> bool: + """마지막 봉이 오늘(KST) 봉인가. + + 스캔 로직은 `closes[-1]`/`volumes[-1]` 을 '오늘(장중)' 값으로 쓴다(모멘텀 v2·눌림목 v2 의 + 오늘 봉 제외 계산 포함). 오늘 봉이 빠진 소스를 그대로 쓰면 어제 종가로 신호·주문가가 + 정해지므로, 그런 소스는 채택하지 않고 다음 소스로 넘어간다. + """ + if not bars_oldest_first: + return False + from app.core.timeutil import today_kst + last = str(bars_oldest_first[-1].get("stck_bsop_date") or "") + return last == today_kst().strftime("%Y%m%d") + + +async def _fetch_scan_daily_bars_v2(api, stock_code: str, min_bars: int) -> tuple[list, str]: + """스캔용 일봉(오래된 → 최신, KIS 필드명). 반환: (bars, source). + + 1) 네이버 일봉 — 오늘 봉으로 끝나고 min_bars 이상이면 채택 + 2) KIS 기간 조회(get_daily_ohlcv_range) — 같은 조건 + 3) 둘 다 실패(빈 결과·오늘 봉 없음·예외)면 기존 get_daily_chart(limit=60) (≈30봉, 기존 동작) + 1·2 가 오늘 봉으로 끝나지만 min_bars 미달이면(신규 상장 등) 그중 긴 쪽을 쓴다. + 봉수 부족 경고는 호출부가 스캔 단위로 집계해 남긴다. + """ + from datetime import timedelta + from app.core.timeutil import today_kst + + best: list = [] + best_src = "" + + # 1) 네이버 + try: + from app.services.naver_finance import get_naver_daily_chart + nv = await get_naver_daily_chart(stock_code, days=_SCAN_NAVER_CHART_DAYS) + nv = [b for b in (nv or []) if isinstance(b, dict) and b.get("stck_bsop_date")] + if _bars_end_today_v2(nv): + if len(nv) >= min_bars: + return nv, "naver" + best, best_src = nv, "naver" + except Exception as e: + warn_throttled( + logger, "entry_service:scan_bars_naver", + f"[ScanBars] {stock_code} 네이버 일봉 조회 실패(KIS 폴백): {type(e).__name__}: {e}", + ) + + # 2) KIS 기간 조회 (FHKST03010100) — 시세 실서버 위임 설정을 get_daily_chart 와 동일하게 따른다 + try: + src_api = api + _delegate = getattr(api, "_quote_delegate", None) + if callable(_delegate): + _d = _delegate() + if _d is not None: + src_api = _d + end = today_kst() + start = end - timedelta(days=_SCAN_KIS_RANGE_CALENDAR_DAYS) + rows = await src_api.get_daily_ohlcv_range( + stock_code, start.strftime("%Y%m%d"), end.strftime("%Y%m%d"), + ) + rows = [r for r in (rows or []) if isinstance(r, dict) and r.get("stck_bsop_date")] + # 최신 → 오래된 순으로 오므로 날짜 기준 정렬 + 중복 제거 + kis = sorted({r["stck_bsop_date"]: r for r in rows}.values(), + key=lambda r: r["stck_bsop_date"]) + if _bars_end_today_v2(kis): + if len(kis) >= min_bars: + return kis, "kis_range" + if len(kis) > len(best): + best, best_src = kis, "kis_range" + except Exception as e: + warn_throttled( + logger, "entry_service:scan_bars_kis_range", + f"[ScanBars] {stock_code} KIS 기간 일봉 조회 실패: {type(e).__name__}: {e}", + ) + + if best: + return best, best_src + + # 3) 기존 경로 (≈30봉). 예외는 호출부의 종목 단위 except 로 전파 — 기존 동작 유지. + chart = await api.get_daily_chart(stock_code, limit=60) + output2 = (chart or {}).get("output2") or [] + return list(reversed(output2)), "kis_daily_price" + + +def _warn_short_scan_bars_v2(tag: str, short: list, min_bars: int) -> None: + """스캔 1회분의 봉수 부족 종목을 경고 1줄로 가시화한다(조용한 미발화 방지).""" + if not short: + return + sample = ", ".join(f"{c}:{n}봉({s})" for c, n, s in short[:5]) + warn_throttled( + logger, f"entry_service:short_bars:{tag}", + f"[{tag}] 일봉 {min_bars}봉 미달 {len(short)}종목 — 해당 전략 판정 불가: {sample}", + ) + + async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]: """듀얼 전략 시그널 스캔 및 진입 실행 (CLAUDE.md 섹션 8.4). @@ -366,29 +490,35 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]: dual_stocks = [] scan_count = 0 + short_bars: list = [] for stock in candidates: if scan_count >= 10: break if stock.stock_code in held_codes: continue - if float(stock.current_price or 0) < settings.MIN_PRICE_KRW: + if _below_min_price_krw_v2(stock.current_price): continue try: - chart = await korea_investment_api.get_daily_chart(stock.stock_code, limit=60) - output2 = chart.get("output2", []) - if not output2 or len(output2) < 20: + # [하드닝 R2 2026-10-09] get_daily_chart 는 30봉 상한이라 눌림목(60봉)이 구조적으로 + # 미발화였다 → 네이버/KIS 기간 조회로 60봉 이상 확보 (_fetch_scan_daily_bars_v2). + bars, bars_src = await _fetch_scan_daily_bars_v2( + korea_investment_api, stock.stock_code, DUAL_SCAN_MIN_BARS, + ) + if len(bars) < DUAL_SCAN_MIN_BARS: + short_bars.append((stock.stock_code, len(bars), bars_src)) + if not bars or len(bars) < 20: continue - # 최신이 앞에 오므로 reverse - bars = list(reversed(output2)) - closes = [float(b.get("stck_clpr", 0)) for b in bars] - highs = [float(b.get("stck_hgpr", 0)) for b in bars] - volumes = [int(b.get("acml_vol", 0)) for b in bars] + closes = [float(b.get("stck_clpr", 0) or 0) for b in bars] + highs = [float(b.get("stck_hgpr", 0) or 0) for b in bars] + volumes = [int(float(b.get("acml_vol", 0) or 0)) for b in bars] if not closes or closes[-1] <= 0: continue + if _below_min_price_krw_v2(closes[-1]): + continue current_close = closes[-1] current_volume = volumes[-1] if volumes else 0 @@ -484,6 +614,8 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]: ) continue + _warn_short_scan_bars_v2("DualStrategy", short_bars, DUAL_SCAN_MIN_BARS) + if not dual_stocks: logger.debug("[DualStrategy] 시그널 없음") return [] @@ -585,7 +717,7 @@ async def scan_range_trading_signals(db: AsyncSession) -> list[dict]: 8. 플레이북 이벤트 차단 9. 보유 종목 중복 진입 방지 10. 연속손실 밴 (loss_tracker.is_banned_safe) - 11. 가격 필터 (MIN_PRICE_KRW) + 11. 가격 필터 (MIN_PRICE_KRW 이하 거부 — _below_min_price_krw_v2) 12. 이후 execute_auto_trade() 내부에서 가격필터·MDD·밴·보유수·당일매도쿨다운, _execute_buy 내부에서 correlation_filter·레버리지ETF한도·뉴스필터· MIN_BUY_AMOUNT_KRW 가 다시 적용된다. @@ -706,6 +838,7 @@ async def scan_range_trading_signals(db: AsyncSession) -> list[dict]: range_stocks: list[dict] = [] scan_count = 0 + short_bars: list = [] for stock in candidates: if scan_count >= settings.RANGE_TRADE_SCAN_LIMIT: @@ -716,7 +849,7 @@ async def scan_range_trading_signals(db: AsyncSession) -> list[dict]: if stock.stock_code in held_codes: continue # 게이트 11: 가격 필터 - if float(stock.current_price or 0) <= settings.MIN_PRICE_KRW: + if _below_min_price_krw_v2(stock.current_price): continue # 게이트 10: 연속손실 밴 (try 밖 — 실패를 삼키지 않는다) if await loss_tracker.is_banned_safe(stock.stock_code): @@ -724,20 +857,24 @@ async def scan_range_trading_signals(db: AsyncSession) -> list[dict]: continue try: - chart = await korea_investment_api.get_daily_chart(stock.stock_code, limit=60) - output2 = chart.get("output2", []) - if not output2 or len(output2) < 40: + # [하드닝 R2 2026-10-09] get_daily_chart 30봉 상한 → 40봉 하한에 항상 걸렸다. + bars, bars_src = await _fetch_scan_daily_bars_v2( + korea_investment_api, stock.stock_code, RANGE_SCAN_MIN_BARS, + ) + if len(bars) < RANGE_SCAN_MIN_BARS: + short_bars.append((stock.stock_code, len(bars), bars_src)) continue - bars = list(reversed(output2)) # 최신이 앞 → 오래된 순으로 - opens = [float(b.get("stck_oprc", 0)) for b in bars] - closes = [float(b.get("stck_clpr", 0)) for b in bars] - highs = [float(b.get("stck_hgpr", 0)) for b in bars] - lows = [float(b.get("stck_lwpr", 0)) for b in bars] - volumes = [int(b.get("acml_vol", 0)) for b in bars] + opens = [float(b.get("stck_oprc", 0) or 0) for b in bars] + closes = [float(b.get("stck_clpr", 0) or 0) for b in bars] + highs = [float(b.get("stck_hgpr", 0) or 0) for b in bars] + lows = [float(b.get("stck_lwpr", 0) or 0) for b in bars] + volumes = [int(float(b.get("acml_vol", 0) or 0)) for b in bars] if not closes or closes[-1] <= 0: continue + if _below_min_price_krw_v2(closes[-1]): + continue scan_count += 1 current_close = closes[-1] @@ -803,6 +940,8 @@ async def scan_range_trading_signals(db: AsyncSession) -> list[dict]: warn_throttled(logger, "entry_service:682", f"[RangeTrade] {stock.stock_code} 스캔 실패: {e}") continue + _warn_short_scan_bars_v2("RangeTrade", short_bars, RANGE_SCAN_MIN_BARS) + if not range_stocks: logger.debug("[RangeTrade] 시그널 없음") return [] diff --git a/backend/app/services/swing/exit_service.py b/backend/app/services/swing/exit_service.py index d524dc7..4bd35de 100644 --- a/backend/app/services/swing/exit_service.py +++ b/backend/app/services/swing/exit_service.py @@ -5,7 +5,8 @@ strategy_type='swing' 포지션만 처리하도록 분리한 서비스. 청산 기준 (CLAUDE.md 섹션 5.2, 6.3): - ATR 손절: 진입가 - 2.5*ATR (하드스탑과 비교하여 타이트한 쪽) - - 고정비율 폴백 손절: -7% (ATR 계산 불가 시) + - 고정비율 폴백 손절: 지표 판정 불가 시. 손절률은 resolve_swing_stop_params_v2 — ATR 을 모르면 + 신선한 캐시 ATR, 그것도 없으면 일반·고변동 중 느슨한 쪽(실시세·대체가 경로 공통) - 1차 익절: RSI >= 55 OR 가격 > BB 중앙선 OR 수익 > 2*ATR -> 50% 매도 - 2차 익절: RSI >= 70 OR 가격 > BB 상단(99%) -> 전량 매도 - 트레일링스탑: 고점 - 1.5*ATR (잔여 50% 물량) @@ -37,7 +38,9 @@ from app.services.market_analyzer import check_market_regime from app.services.mdd_manager import mdd_manager from app.services.loss_tracker import loss_tracker from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade -from app.services.swing.position_manager import get_swing_holdings, get_swing_active_trade +from app.services.swing.position_manager import ( + get_swing_holdings, get_swing_active_trade, resolve_swing_entry_price_v2, +) from app.services.utils import _is_domestic from app.core.json_helpers import _dumps @@ -54,6 +57,357 @@ _EMPTY_HOLDINGS_WARN_AFTER: int = 10 EXIT_COOLDOWN_SEC = 180 +def _exit_cooldown_applies_v2(exit_signal, domestic: bool) -> bool: + """[하드닝 R8 2026-10-10] 청산 쿨다운(EXIT_COOLDOWN_SEC)을 이 신호에 적용할지. + + 쿨다운의 목적은 큐 지연으로 같은 '분할' 단계가 반복 매도되는 것을 막는 것이다. + 국내 전량형 신호(sell_pct >= 1.0: 손절·시간손절·폴백 손절·전량 익절)는 적용하지 않는다 — + 청산 우선(§8.1-11)이며 과다매도는 매도 직전 KIS 실보유 상한이 막는다. + 해외는 실보유 상한이 없으므로 모든 신호에 적용한다. sell_pct 를 해석할 수 없으면 + 전량(1.0)으로 본다(손절 보류를 피하는 쪽 — 국내 수량은 KIS 상한이 제한). + """ + if not domestic: + return True + try: + _sp = float(getattr(exit_signal, "sell_pct", 1.0)) + except (TypeError, ValueError): + _sp = 1.0 + if _sp != _sp: # NaN + _sp = 1.0 + return _sp < 1.0 + +# ───────────────────────────────────────────────────────────────────────────── +# [하드닝 R4 2026-10-09] 청산 판정 결과 집계 · 종목별 연속 예외 · 매도 거부 감시 +# +# (1) 이전에는 바깥 except("전체 오류")와 종목별 except 가 로그만 남기고 정상 반환했다. +# 스케줄러는 예외를 받지 못해 매 사이클 _record_exit_success_v2 를 호출 → 연속 실패 +# 경보·무완료(stale) 경보가 영구 미발동했다(DB 풀 고갈 등으로 손절이 몇 시간 멈춰도 무경보). +# 이제 매 실행 결과(ok/오류)를 _exit_run_state_v2 에 남기고, 스케줄러가 실행 일련번호 +# (seq)로 이번 실행분을 확인해 실패로 집계한다. 실패 = 전체 오류 OR 보유 종목 전부 예외. +# 일부 종목만 예외면 실행은 성공이지만 종목별 연속 예외 카운터가 임계 도달 시 경보한다. +# (2) KIS 업무거부·전송 전 실패로 매도가 안 나간 경우(outcome=retry)가 매 60초 같은 거부를 +# 반복해도 흔적이 없었다. 종목별 연속 거부를 세고 임계 도달 시 텔레그램, 계좌 단위 거부 +# (KIS_ACCOUNT_REJECT_MSG_CODES, 예: 40910000 계좌 교체)는 1회째에 즉시 텔레그램. +# 둘 다 get_swing_exit_health_v2()["degraded_reasons"] 로 /health 에 노출된다. +# 카운터 갱신은 try 밖(가드), 경보 전송 실패는 warning 으로만 남기고 청산 흐름을 막지 않는다. +# ───────────────────────────────────────────────────────────────────────────── +_DEFAULT_EXIT_SYMBOL_ERROR_ALERT_COUNT = 3 +_DEFAULT_SELL_REJECT_ALERT_COUNT = 3 +_exit_run_state_v2: dict = { + "seq": 0, # 실행 완료 일련번호(스케줄러가 이번 실행분인지 판정) + "ok": True, + "error": None, # 실패 사유(문자열) + "holdings": 0, + "errored": 0, # 이번 실행에서 예외가 난 종목 수 + "errors": [], # "종목: 예외" 목록(최대 10개) +} +_swing_exit_error_counts: dict[str, int] = {} # 종목 -> 연속 판정 예외 횟수 +_swing_sell_reject_counts: dict[str, int] = {} # 종목 -> 연속 매도 거부(미전송 포함) 횟수 +_swing_sell_reject_last: dict[str, dict] = {} # 종목 -> {"msg_cd","msg","account"} + + +def _exit_alert_threshold_v2(name: str, default: int) -> int: + """settings 정수 임계(1 이상). 누락·비정상이면 기본값 — 경보를 끄는 값은 허용하지 않는다.""" + raw = getattr(settings, name, None) + try: + val = int(raw) if raw is not None else int(default) + except (TypeError, ValueError): + logger.warning(f"[SwingExit] 설정 {name}={raw!r} 비정상 — 기본값 {default} 사용") + return int(default) + if val < 1: + logger.warning(f"[SwingExit] 설정 {name}={raw!r} < 1 — 기본값 {default} 사용") + return int(default) + return val + + +def _notify_swing_exit_alert_v2(key: str, category: str, message: str) -> None: + """스윙 스케줄러의 텔레그램 경보(_notify_throttled_v2, key 별 재전송 억제)로 보낸다. + + 지연 import — scheduler 가 이 모듈을 import 하므로 순환을 피한다. 실패는 warning(§4). + """ + try: + from app.services.swing.scheduler import _notify_throttled_v2 + _notify_throttled_v2(key, category, message) + except Exception as e: + logger.warning(f"[SwingExit] 텔레그램 경보 예약 실패({key}): {type(e).__name__}: {e}") + + +def _account_reject_codes_v2() -> set: + try: + return {str(c) for c in (getattr(settings, "KIS_ACCOUNT_REJECT_MSG_CODES", None) or ())} + except TypeError: + logger.warning("[SwingExit] KIS_ACCOUNT_REJECT_MSG_CODES 비정상 — 계좌 거부 즉시 경보 비활성") + return set() + + +def _note_swing_sell_reject_v2(stock_code: str, msg_cd, err) -> int: + """매도 미전송·업무거부 1회 기록 + 임계/계좌거부 경보. 반환: 연속 횟수.""" + n = _swing_sell_reject_counts.get(stock_code, 0) + 1 + _swing_sell_reject_counts[stock_code] = n + _mc = str(msg_cd or "") + is_account = bool(_mc) and _mc in _account_reject_codes_v2() + _swing_sell_reject_last[stock_code] = {"msg_cd": _mc or None, "msg": str(err), "account": is_account} + if is_account: + msg = ( + f"스윙 매도 계좌 단위 거부(msg_cd={_mc}) — {stock_code} 손절·익절 미체결({n}회 연속). " + f"계좌/앱키 교체 여부 확인 필요(키 3개 교체 + compose 재생성): {err}" + ) + logger.error(f"[SwingExit] 🚨 {msg}") + _notify_swing_exit_alert_v2("sell_reject_account", "SWING_EXIT", msg) + return n + threshold = _exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", _DEFAULT_SELL_REJECT_ALERT_COUNT) + if n >= threshold: + msg = ( + f"스윙 매도 연속 실패 {n}회 ({stock_code}, msg_cd={_mc or None}) — " + f"청산 신호가 체결되지 않고 있음(거래정지·주문가능수량 부족·미체결 잔존 등 확인): {err}" + ) + logger.error(f"[SwingExit] 🚨 {msg}") + _notify_swing_exit_alert_v2(f"sell_reject:{stock_code}", "SWING_EXIT", msg) + return n + + +def _clear_swing_sell_reject_v2(stock_code: str) -> None: + n = _swing_sell_reject_counts.pop(stock_code, 0) + _swing_sell_reject_last.pop(stock_code, None) + if n: + logger.warning(f"[SwingExit] {stock_code} 매도 접수 정상화 (연속 실패 {n}회 후)") + + +def _note_exit_symbol_error_v2(stock_code: str, err: str) -> int: + n = _swing_exit_error_counts.get(stock_code, 0) + 1 + _swing_exit_error_counts[stock_code] = n + threshold = _exit_alert_threshold_v2( + "SWING_EXIT_SYMBOL_ERROR_ALERT_COUNT", _DEFAULT_EXIT_SYMBOL_ERROR_ALERT_COUNT + ) + if n >= threshold: + msg = f"스윙 청산 판정 종목 오류 {n}회 연속 ({stock_code}) — 이 종목 손절·익절이 멈춰 있음: {err}" + logger.error(f"[SwingExit] 🚨 {msg}") + _notify_swing_exit_alert_v2(f"exit_symbol_error:{stock_code}", "SWING_EXIT", msg) + return n + + +def _record_exit_run_v2(holding_codes: list, errored: dict, fatal: "str | None") -> None: + """이번 실행 결과 기록. 종목별 연속 예외 카운터 갱신(예외 없던 보유 종목은 0으로). + + 보유 목록에서 사라진 종목의 카운터는 정리한다(매도 거부 카운터 포함). 전체 오류(fatal)면 + 보유 목록을 모르므로 카운터는 건드리지 않는다. + """ + if fatal is None: + held = set(holding_codes) + for code in list(_swing_exit_error_counts): + if code not in errored: + _swing_exit_error_counts.pop(code, None) + for code in list(_swing_sell_reject_counts): + if code not in held: + _swing_sell_reject_counts.pop(code, None) + _swing_sell_reject_last.pop(code, None) + n_hold = len(holding_codes) + all_failed = n_hold > 0 and len(errored) >= n_hold + ok = fatal is None and not all_failed + if fatal is not None: + error = f"전체 오류: {fatal}" + elif all_failed: + first = next(iter(errored.items())) + error = f"보유 {n_hold}종목 전부 판정 예외 (예: {first[0]}: {first[1]})" + else: + error = None + _exit_run_state_v2.update({ + "seq": _exit_run_state_v2["seq"] + 1, + "ok": ok, + "error": error, + "holdings": n_hold, + "errored": len(errored), + "errors": [f"{c}: {e}" for c, e in list(errored.items())[:10]], + }) + + +def get_last_exit_run_status_v2() -> dict: + """마지막 check_and_execute_exit_v2 실행 결과(복사본). seq 로 실행분을 구분한다.""" + st = dict(_exit_run_state_v2) + st["errors"] = list(st["errors"]) + return st + + +async def check_and_execute_exit_v2_impl(db: AsyncSession) -> "tuple[list[dict], bool, list[str]]": + """check_and_execute_exit_v2 + 실행 결과. 반환 (exits, ok, errors). + + ok=False — 전체 오류 또는 보유 종목 전부 예외. errors — 실패 사유·종목별 예외 목록. + """ + exits = await check_and_execute_exit_v2(db) + st = get_last_exit_run_status_v2() + errors = ([st["error"]] if st["error"] else []) + st["errors"] + return exits, bool(st["ok"]), errors + + +def get_swing_exit_health_v2() -> dict: + """청산 판정·매도 거부 감시 상태(읽기 전용) — 스케줄러 /health 에 합쳐진다.""" + sym_th = _exit_alert_threshold_v2( + "SWING_EXIT_SYMBOL_ERROR_ALERT_COUNT", _DEFAULT_EXIT_SYMBOL_ERROR_ALERT_COUNT + ) + rej_th = _exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", _DEFAULT_SELL_REJECT_ALERT_COUNT) + reasons: list = [] + if not _exit_run_state_v2["ok"]: + reasons.append("exit_run_failed") + if any(n >= sym_th for n in _swing_exit_error_counts.values()): + reasons.append("exit_symbol_errors") + if any(n >= rej_th for n in _swing_sell_reject_counts.values()): + reasons.append("swing_sell_rejects") + if any(v.get("account") for v in _swing_sell_reject_last.values()): + reasons.append("swing_sell_account_reject") + # [하드닝 R1-b 2026-10-09] 청산 판정 대상 밖 보유(무경보 휴면) · 잔고 첫 페이지 포화 근접 + reasons.extend(_census_degraded_reasons_v2()) + return { + "last_run": get_last_exit_run_status_v2(), + "symbol_error_counts": dict(_swing_exit_error_counts), + "sell_reject_counts": dict(_swing_sell_reject_counts), + "sell_reject_last": {k: dict(v) for k, v in _swing_sell_reject_last.items()}, + "empty_holdings_streak": int(_empty_holdings_streak), + "unowned_streak": int(_unowned_streak), + "census": dict(_census_state_v2), + "degraded_reasons": reasons, + } + + +# ───────────────────────────────────────────────────────────────────────────── +# [하드닝 R1-b 2026-10-09] 청산 루프 휴면·잔고 페이지 포화 감시 +# +# (1) 2026-05-15~09-21 4개월 휴면: get_swing_holdings 가 빈 목록을 반환해도 실행은 '성공'으로 +# 기록되고 info/warning 로그뿐이었다. 이제 매 주기 Portfolio(qty>0)를 함께 SELECT 해 +# **어느 엔진의 태그도 아닌 행**(strategy_type NULL·미상)이 있으면 연속 주기를 세고, 임계 +# (SWING_EXIT_UNOWNED_ALERT_CYCLES, 기본 5) 도달 시 error + 텔레그램 + /health degraded +# ('exit_zero_eval' = 스윙 판정 0종목인데 미소유 보유 존재 / 'exit_unowned_holdings'). +# daytrading 태그 고아(DT 인메모리 0)는 position_manager.detect_strategy_tag_mismatch_v2 소관. +# (2) get_balance 는 첫 페이지(모의 20행)만 본다(§8.5-37). 국내 보유 행 수가 +# SWING_BALANCE_PAGE_WARN_ROWS(미설정 시 SWING_BALANCE_FULL_PAGE_ROWS − 4) 이상이면 +# 손절 보류 절벽에 근접 — error + 텔레그램 + 'balance_page_near_full'. +# 경보 전용이라 실패해도 청산 판정을 막지 않는다(조회만 try, 카운터는 결과가 있을 때만 갱신). +# ───────────────────────────────────────────────────────────────────────────── +_SWING_EVAL_KNOWN_TAGS = ("swing", "daytrading", "external", "manual", "us_swing") +_DEFAULT_EXIT_UNOWNED_ALERT_CYCLES = 5 +_BALANCE_PAGE_WARN_MARGIN = 4 +_unowned_streak: int = 0 +_census_state_v2: dict = {} + + +def _balance_page_warn_rows_v2() -> int: + raw = getattr(settings, "SWING_BALANCE_PAGE_WARN_ROWS", None) + if raw is not None: + return _exit_alert_threshold_v2("SWING_BALANCE_PAGE_WARN_ROWS", 16) + try: + full = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) + except (TypeError, ValueError, AttributeError): + logger.warning("[SwingExit] SWING_BALANCE_FULL_PAGE_ROWS 비정상 — 페이지 포화 경보 임계 16 사용") + return 16 + return max(1, full - _BALANCE_PAGE_WARN_MARGIN) + + +async def _portfolio_census_v2(db: AsyncSession) -> "dict | None": + """Portfolio(qty>0) 태그 집계. 조회 실패면 None(+warning). + + DB-READ-EXCEPTION: SELECT 전용(commit·add 없음) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 exit_session. + """ + try: + res = await db.execute( + select(Portfolio.stock_code, Portfolio.strategy_type).where(Portfolio.quantity > 0) + ) + rows = [(str(r[0] or ""), r[1]) for r in res.all()] + unowned = sorted( + c for c, t in rows if t is None or str(t) not in _SWING_EVAL_KNOWN_TAGS + ) + domestic_rows = sum(1 for c, _ in rows if c and _is_domestic(c)) + except Exception as e: + warn_throttled( + logger, "exit_service:census", + f"[SwingExit] Portfolio 보유 집계 실패(휴면·페이지 감시 이번 주기 생략): {type(e).__name__}: {e}", + ) + return None + return {"rows": len(rows), "domestic_rows": domestic_rows, "unowned": unowned} + + +def _apply_census_v2(census: "dict | None", n_holdings: int) -> None: + """집계 결과로 연속 카운터·경보 갱신. census None(조회 실패)이면 상태 유지.""" + global _unowned_streak + if census is None: + return + _census_state_v2.clear() + _census_state_v2.update({**census, "unowned": list(census["unowned"]), "holdings": int(n_holdings)}) + from app.core.log_throttle import should_log + unowned = census["unowned"] + if unowned: + _unowned_streak += 1 + th = _exit_alert_threshold_v2("SWING_EXIT_UNOWNED_ALERT_CYCLES", _DEFAULT_EXIT_UNOWNED_ALERT_CYCLES) + if _unowned_streak >= th: + msg = ( + f"스윙 청산 판정 대상 밖 보유 {len(unowned)}종목 {unowned[:5]} " + f"(Portfolio.strategy_type 미지정/미상, {_unowned_streak}주기 연속) — 스윙 판정 " + f"{n_holdings}종목만 진행 중, 위 종목은 손절이 걸려 있지 않음. 소유 태그 확인 필요" + ) + _ok, _sup = should_log("exit_service:unowned_holdings") + if _ok: + logger.error(f"[SwingExit] 🚨 {msg}" + (f" (+{_sup}건 억제)" if _sup else "")) + _notify_swing_exit_alert_v2( + "exit_zero_eval" if n_holdings == 0 else "exit_unowned_holdings", "SWING_EXIT", msg, + ) + else: + _unowned_streak = 0 + warn_rows = _balance_page_warn_rows_v2() + if int(census["domestic_rows"]) >= warn_rows: + msg = ( + f"국내 보유 {census['domestic_rows']}행 ≥ {warn_rows} — KIS 잔고 첫 페이지" + f"({settings.SWING_BALANCE_FULL_PAGE_ROWS}행) 포화 근접. 넘으면 2페이지 종목의 스윙 손절이 " + f"보류된다(get_balance 연속조회 미구현, CLAUDE.md §8.5-37)" + ) + _ok, _sup = should_log("exit_service:balance_page_near_full") + if _ok: + logger.error(f"[SwingExit] 🚨 {msg}" + (f" (+{_sup}건 억제)" if _sup else "")) + _notify_swing_exit_alert_v2("balance_page_near_full", "SWING_EXIT", msg) + + +def _census_degraded_reasons_v2() -> list: + reasons: list = [] + th = _exit_alert_threshold_v2("SWING_EXIT_UNOWNED_ALERT_CYCLES", _DEFAULT_EXIT_UNOWNED_ALERT_CYCLES) + if _unowned_streak >= th: + reasons.append( + "exit_zero_eval" if int(_census_state_v2.get("holdings", 0) or 0) == 0 + else "exit_unowned_holdings" + ) + try: + if int(_census_state_v2.get("domestic_rows", 0) or 0) >= _balance_page_warn_rows_v2(): + reasons.append("balance_page_near_full") + except (TypeError, ValueError): + reasons.append("census_unreadable") + return reasons + + +def _effective_exit_reason_v2( + reason: str, signal_sell_pct: float, sell_qty: int, swing_basis: int, original_qty: int, +) -> str: + """기록용 청산 사유 — 신호 사유를 실제 매도 수량에 맞춘다(§4 설명 문자열 = 실효값). + + [하드닝 R1-b 2026-10-09] 3주 이하 소량·KIS 상한으로 매도 수량이 신호와 달라져도 + check_exit_signal 의 '1차 익절(50%)' 이 그대로 저장돼 저널·ML 학습·분할/전량 집계가 오염됐다 + (id330 KT&G 2주, id413 아모레퍼시픽 3주, id385 LG화학 1주 — 전부 전량 매도). + 접두어에는 '손절'·'익절'·'트레일링' 등 분류 키워드를 넣지 않는다(trade_analyzer._parse_exit_type + 분류는 원 사유가 결정). + """ + # 기록 문자열 가공 실패가 매도를 막지 않도록 원 사유로 폴백한다(청산 우선 §8.1-11). + try: + sp = float(signal_sell_pct) + sq, sb = int(sell_qty), int(swing_basis) + base = int(original_qty) if int(original_qty or 0) > 0 else sb + actual = (f"{sq / base:.0%}" if base > 0 else "?") + if sp < 1.0 and sq >= sb: + return ( + f"소량 전량매도(분할 신호 {sp:.0%} → 실제 {sq}/{sb}주 전량, " + f"원본 대비 {actual}): {reason}" + ) + if sp >= 1.0 and sq < sb: + return f"KIS 상한 부분매도(전량 신호 → 실제 {sq}/{sb}주): {reason}" + except Exception as e: + logger.warning(f"[SwingExit] 청산 사유 가공 실패 — 원 사유 기록: {type(e).__name__}: {e}") + return str(reason) + + def _check_momentum_exit( current_price: float, entry_price: float, @@ -116,11 +470,14 @@ def _check_momentum_exit( # 트레일링스탑 (잔량에 대해) if trailing_high and trailing_high > entry_price: - trail_stop = trailing_high - 1.5 * atr + # [R3 2026-10-09] 리터럴 1.5 → 평균회귀와 같은 단일 출처(기본값 동일 1.5) + from app.services.mean_reversion_strategy import resolve_swing_trailing_atr_mult_v2 + _trail_mult = resolve_swing_trailing_atr_mult_v2() + trail_stop = trailing_high - _trail_mult * atr if current_price <= trail_stop and profit_pct > 0: return ExitSignal( action="SELL_ALL", - reason=f"모멘텀 트레일링 (고점={trailing_high:.0f}, 트레일={trail_stop:.0f})", + reason=f"모멘텀 트레일링 (고점={trailing_high:.0f}, 트레일={trail_stop:.0f}=고점-{_trail_mult:g}×ATR)", sell_pct=1.0, ) @@ -156,6 +513,162 @@ _TRANSPORT_UNKNOWN_MSG_CD_V2 = "KIS_TRANSPORT_UNKNOWN" # {종목: {"qty": 불명 주문수량, "pre_total": 주문 직전 Portfolio 합산수량, "since": epoch, "failures": n}} _pending_unknown_sells: dict[str, dict] = {} +# ── [하드닝 R2 2026-10-09] 결과 불명 매도 대기 영속화 (Redis 당일 해시) ───────────────── +# 대기가 인메모리 전용이라 재기동하면 체결분의 SELL TradeHistory·remaining_pct·전량 close· +# loss_tracker 가 영영 반영되지 않고, remaining_pct=1.0 이 남아 같은 분할 단계가 재발동할 수 +# 있었다. 등록·해제마다 Redis 해시(당일 KST 키)에 미러링하고, 기동 후 첫 청산 주기에 복원해 +# 기존 KIS 잔고 대조(_reconcile_unknown_sell_v2)로 흘려보낸다. +# - Redis 미연결(클라이언트 None): 이 프로세스에서는 영속화·복원 자체가 불가능하고 재시도도 +# 의미가 없다 → warning 후 보류 없이 진행(기존 동작). +# - Redis 조회 예외(일시 장애): 복원 실패 → 그 주기 국내 **분할(부분) 익절 매도만** 보류하고 +# 다음 주기 재시도. _UNKNOWN_SELL_RESTORE_HOLD_CYCLES 주기 보류 후에도 실패면 error 후 진행. +# 손절·전량 청산은 보류하지 않는다(청산 우선, §8.1-11 — 수량 상한은 매도 직전 KIS 실보유). +_UNKNOWN_SELL_REDIS_KEY_PREFIX = "swing:unknown_sells:" +_UNKNOWN_SELL_REDIS_TTL_SEC = 2 * 86400 +_UNKNOWN_SELL_RESTORE_HOLD_CYCLES = 1 +# "pending" → 복원 미시도/재시도 대기, "ok" → 복원 완료(또는 Redis 미연결로 복원 불가 확정), +# "gave_up" → 보류 한도 초과로 복원 포기 +_unknown_sells_restore_state: str = "pending" +_unknown_sells_restore_failures: int = 0 +_UNKNOWN_SELL_REQUIRED_KEYS_V2 = ( + "qty", "pre_hldg", "price", "avg_price", "swing_qty", "sell_pct", "remaining_pct", + "original_qty", "filter_not_dt", "had_active_trade", "stock_name", "reason", +) + + +def _unknown_sells_redis_key_v2() -> str: + from app.core.timeutil import today_kst + return f"{_UNKNOWN_SELL_REDIS_KEY_PREFIX}{today_kst():%Y%m%d}" + + +async def _persist_unknown_sell_v2(stock_code: str, entry: dict) -> bool: + """대기 1건을 Redis 에 미러링. 실패는 warning(인메모리 대기는 그대로 유효).""" + from app.core.redis import get_redis + client = get_redis() + if client is None: + logger.warning( + f"[SwingExit] {stock_code}: Redis 미연결 — 결과 불명 매도 대기 영속화 불가 " + "(재기동 시 체결분 장부 반영 누락 가능, 수동 확인 필요)" + ) + return False + try: + key = _unknown_sells_redis_key_v2() + await client.hset(key, stock_code, _dumps(entry, default=float)) + await client.expire(key, _UNKNOWN_SELL_REDIS_TTL_SEC) + return True + except Exception as e: + logger.warning( + f"[SwingExit] {stock_code}: 결과 불명 매도 대기 Redis 저장 실패 — {type(e).__name__}: {e} " + "(재기동 시 체결분 장부 반영 누락 가능)" + ) + return False + + +async def _forget_unknown_sell_v2(stock_code: str) -> None: + """대기 해제를 Redis 에 반영. 실패는 warning — 남은 항목은 복원 후 KIS 대조로 다시 해제된다.""" + from app.core.redis import get_redis + client = get_redis() + if client is None: + return + try: + await client.hdel(_unknown_sells_redis_key_v2(), stock_code) + except Exception as e: + logger.warning( + f"[SwingExit] {stock_code}: 결과 불명 매도 대기 Redis 해제 실패 — {type(e).__name__}: {e}" + ) + + +def _parse_restored_unknown_sell_v2(stock_code: str, raw) -> "dict | None": + import json + try: + entry = json.loads(raw) + if not isinstance(entry, dict) or any(k not in entry for k in _UNKNOWN_SELL_REQUIRED_KEYS_V2): + raise ValueError("필수 키 누락") + for k in ("qty", "pre_hldg", "swing_qty", "original_qty"): + entry[k] = int(entry[k]) + for k in ("price", "avg_price", "sell_pct", "remaining_pct"): + entry[k] = float(entry[k]) + if entry.get("prior_realized_net") is not None: + entry["prior_realized_net"] = float(entry["prior_realized_net"]) + if entry["qty"] <= 0: + raise ValueError("qty<=0") + return entry + except (TypeError, ValueError) as e: + logger.error( + f"[SwingExit] 🚨 {stock_code}: Redis 결과 불명 매도 대기 형식 이상 — 복원 불가({e}), 수동 확인 필요" + ) + return None + + +async def _restore_unknown_sells_v2() -> bool: + """기동 후 Redis 에서 결과 불명 매도 대기를 복원. 반환 = 이 주기 분할 익절 매도를 보류할지. + + 인메모리에 이미 있는 종목은 덮어쓰지 않는다(이 프로세스의 등록이 최신). + """ + global _unknown_sells_restore_state, _unknown_sells_restore_failures + if _unknown_sells_restore_state != "pending": + return False + from app.core.redis import get_redis + client = get_redis() + if client is None: + _unknown_sells_restore_state = "ok" + logger.warning( + "[SwingExit] Redis 미연결 — 결과 불명 매도 대기 복원 불가(이전 프로세스의 불명 매도가 있었다면 " + "체결분 장부 반영 누락 가능, 수동 확인 필요)" + ) + return False + try: + raw_map = await client.hgetall(_unknown_sells_redis_key_v2()) + except Exception as e: + _unknown_sells_restore_failures += 1 + if _unknown_sells_restore_failures > _UNKNOWN_SELL_RESTORE_HOLD_CYCLES: + _unknown_sells_restore_state = "gave_up" + logger.error( + f"[SwingExit] 🚨 결과 불명 매도 대기 복원 {_unknown_sells_restore_failures}회 실패 — " + f"복원 포기, 분할 익절 보류 해제 ({type(e).__name__}: {e}). 재기동 전 불명 매도가 있었다면 " + "remaining_pct·ActiveTrade 수동 확인 필요" + ) + return False + logger.warning( + f"[SwingExit] 결과 불명 매도 대기 복원 실패({_unknown_sells_restore_failures}회) — " + f"이 주기 분할 익절 매도 보류, 다음 주기 재시도 ({type(e).__name__}: {e})" + ) + return True + restored = 0 + for code, raw in (raw_map or {}).items(): + code = code.decode() if isinstance(code, bytes) else str(code) + if code in _pending_unknown_sells: + continue + entry = _parse_restored_unknown_sell_v2(code, raw) + if entry is None: + continue + _pending_unknown_sells[code] = entry + restored += 1 + _unknown_sells_restore_state = "ok" + if restored: + logger.warning( + f"[SwingExit] 재기동 전 결과 불명 매도 대기 {restored}건 복원 — KIS 잔고 대조로 장부 반영 예정" + ) + return False + + +async def _reconcile_orphan_unknown_sells_v2(holding_codes: set) -> None: + """보유 목록에 없는 종목의 결과 불명 대기를 KIS 잔고로 대조. + + 재기동 후 Portfolio sync 가 전량 체결된 행을 지우면 그 종목은 청산 루프에 들어오지 않아, + 대조가 영영 안 되고 ActiveTrade 가 유령으로 남는다. 대조 불가(잔고 조회 실패)면 대기를 유지. + """ + for code in [c for c in list(_pending_unknown_sells) if c not in holding_codes]: + try: + snap = await _presell_kis_holding_v2(code) + if snap is None: + continue + await _reconcile_unknown_sell_v2(code, *snap) + except Exception as e: + logger.warning( + f"[SwingExit] {code}: 보유 목록 밖 결과 불명 매도 대조 실패 — {type(e).__name__}: {e} (대기 유지)" + ) + def _classify_swing_sell_response_v2(resp) -> str: """매도 주문 응답 → "accepted" | "unknown" | "retry". @@ -251,8 +764,10 @@ def _trigger_portfolio_sync_v2() -> None: 다음 주기의 KIS 잔고 대조(_reconcile_unknown_sell_v2)다. """ try: - from app.services.trade_executor import _delayed_sync_portfolio - asyncio.get_running_loop().create_task(_delayed_sync_portfolio()) + from app.services.trade_executor import _delayed_sync_portfolio, spawn_tracked_task_v2 + # [하드닝 R2] 강참조·종료 drain 대상(trade_executor 레지스트리) — bare create_task 금지 + asyncio.get_running_loop() # 루프 없으면 코루틴을 만들기 전에 RuntimeError + spawn_tracked_task_v2(_delayed_sync_portfolio(), name="swing_unknown_sell_sync") except Exception as e: logger.warning(f"[SwingExit] 결과 불명 매도 후 Portfolio sync 요청 실패: {type(e).__name__}: {e}") @@ -273,16 +788,20 @@ async def _reconcile_unknown_sell_v2(stock_code: str, kis_hldg: int, kis_sellabl if filled <= 0: if kis_sellable >= kis_hldg: _pending_unknown_sells.pop(stock_code, None) + await _forget_unknown_sell_v2(stock_code) logger.warning( f"[SwingExit] {stock_code}: 결과 불명 매도({pend['qty']}주) 미체결 확인 " f"(KIS 보유 {kis_hldg}주 불변) — 대기 해제, 다음 주기 재판정" ) return False _pending_unknown_sells.pop(stock_code, None) + await _forget_unknown_sell_v2(stock_code) price = float(pend["price"]) avg = float(pend["avg_price"]) swing_qty = int(pend["swing_qty"]) is_full = filled >= swing_qty + # [2026-10-09 R1] 순손익(비용 차감) — 판정·기록 모두 이 값(§5.1). 결과 불명 대기는 국내만 등록. + _pnl = _swing_net_pnl_v2(avg, price, filled, pend.get("market"), domestic=True) logger.warning( f"[SwingExit] {stock_code}: 결과 불명 매도 체결 확인 — {filled}/{pend['qty']}주 " f"(KIS 보유 {pend['pre_hldg']}→{kis_hldg}) → 장부 반영" @@ -293,13 +812,17 @@ async def _reconcile_unknown_sell_v2(stock_code: str, kis_hldg: int, kis_sellabl "order_type": "SELL", "order_price": price, "order_quantity": int(pend["qty"]), - "executed_price": price, + # [2026-10-08] 주문번호가 없어 체결가를 조회할 수 없다 — 신호가를 체결가로 쓰지 않는다 + # (total_amount·손익은 신호가 기준 추정치로 남는다). + "executed_price": None, "executed_quantity": filled, "total_amount": price * filled, "status": "COMPLETED", "order_id": "", "exit_reason": f"{pend['reason']} (결과 불명 → KIS 잔고로 체결 확인)", - "net_profit_loss": (price - avg) * filled, + "commission": _pnl["total_cost"], + "net_profit_loss": _pnl["net_profit_loss"], + # 수익률은 기존 executor 규약대로 비용 전(gross) — 체결 확인(net_rate=False)과 동일 "profit_loss_rate": ((price - avg) / avg * 100) if avg > 0 else 0.0, "strategy_type": "swing", }) @@ -313,7 +836,10 @@ async def _reconcile_unknown_sell_v2(stock_code: str, kis_hldg: int, kis_sellabl "data": {"stock_code": stock_code, "strategy_name_not": list(_DTN)}}) else: await enqueue_close_active_trade(stock_code, strategy_name=pend["strategy"] or "swing") - await loss_tracker.record_trade_result_safe(stock_code=stock_code, is_loss=(price - avg) < 0) + await loss_tracker.record_trade_result_safe( + stock_code=stock_code, + is_loss=_position_is_loss_v2(pend.get("prior_realized_net"), _pnl["net_profit_loss"]), + ) await loss_tracker.save_to_db(stock_code=stock_code) _last_live_prices.pop(stock_code, None) _last_atr.pop(stock_code, None) @@ -361,6 +887,242 @@ async def _alert_no_price_v2(stock_code: str, n: int) -> None: logger.warning(f"[SwingExit] {stock_code} 손절 skip 텔레그램 경보 실패: {type(e).__name__}: {e}") +# ─── 청산 RSI 워밍업용 장기 일봉 (하드닝 R4 2026-10-09) ───────────────────────── +# KIS get_daily_chart(FHKST01010400)는 limit 과 무관하게 30행만 돌려준다(entry_service 주석, +# 로컬 로그 N=30). Wilder RSI(14)를 30봉으로 계산하면 시드 영향이 ≈(13/14)^15≈33% 남아 +# 1차 익절(RSI≥55)·전량(RSI≥70) 판정이 전체 이력 RSI 와 어긋났다(운영 150종목×120봉 실측: +# |Δ| 평균 2.58·p95 6.96, ≥55 불일치 6.9%, ≥70 불일치 1.3%). 백테스트는 전체 이력을 쓴다. +# +# 설계: 장기 이력(네이버 → KIS 기간 조회)은 종목당 **하루 1회** 백그라운드로 받아 캐시하고, +# 매 사이클의 KIS 30봉(오늘 봉 포함, 실시세에 가까움)과 **날짜로 병합**해 RSI 만 계산한다. +# - KIS 30봉이 겹치는 날짜를 덮어쓴다 → 최근 30봉 값·오늘 봉은 기존과 동일 출처. +# - 겹치는 확정 봉의 종가가 어긋나면(수정주가 차이·소스 오류) 병합을 버리고 기존 30봉으로 폴백. +# - ATR(최근 14 TR 단순평균)·BB(최근 20봉)·lows[-5:] 는 30봉으로 이미 정확하므로 그대로 둔다. +# 청산 루프 지연 = 손절 지연이므로 조회 대기는 EXIT_HISTORY_WAIT_SEC 로 상한을 둔다. 대기 안에 +# 끝나지 않으면 이번 사이클은 기존 30봉 RSI 로 판정하고(손절 판정은 영향 없음) 다음 사이클에 쓴다. +EXIT_RSI_HISTORY_MIN_BARS = 100 # 병합 후 이 봉수 미만이면 warning(그래도 30봉보다 길면 사용) +_EXIT_HISTORY_NAVER_DAYS = 150 # get_naver_daily_chart: 캘린더 (N+30)일 ≈ 120 거래일 +_EXIT_HISTORY_KIS_CALENDAR_DAYS = 180 +EXIT_HISTORY_WAIT_SEC = 2.0 # 사이클 내 대기 상한(초) — 손절 지연 방지 +_EXIT_HISTORY_FETCH_TIMEOUT_SEC = 15.0 # 백그라운드 소스별 조회 상한 +_EXIT_HISTORY_RETRY_SEC = 1800.0 # 실패 후 재조회 간격(초) +_EXIT_HISTORY_CLOSE_TOL = 0.005 # 겹치는 확정 봉 종가 허용 오차(0.5%) + +# stock_code -> {"date": "YYYYMMDD"(KST), "bars": list|None, "src": str, "failed_at": monotonic|None} +_exit_history_cache: dict[str, dict] = {} +_exit_history_tasks: dict[str, "asyncio.Task"] = {} +# 사이클별 RSI 출처 집계(A-B 관측용): "merged" / "kis30" +_exit_rsi_source_counts: dict[str, int] = {"merged": 0, "kis30": 0} + + +def _bar_close_v2(bar) -> "float | None": + try: + c = float(bar.get("stck_clpr")) + except (TypeError, ValueError, AttributeError): + return None + if c != c or c <= 0: # NaN·0·음수 + return None + return c + + +async def _fetch_exit_history_bars_v2(stock_code: str) -> tuple[list, str]: + """장기 일봉(오래된 → 최신). 네이버 → KIS 기간 조회. 둘 다 실패면 ([], ""). + + 예외를 던지지 않는다(호출부가 백그라운드 태스크). + """ + from datetime import timedelta + from app.core.timeutil import today_kst + + best: list = [] + best_src = "" + try: + from app.services.naver_finance import get_naver_daily_chart + nv = await asyncio.wait_for( + get_naver_daily_chart(stock_code, days=_EXIT_HISTORY_NAVER_DAYS), + timeout=_EXIT_HISTORY_FETCH_TIMEOUT_SEC, + ) + nv = [b for b in (nv or []) if isinstance(b, dict) and b.get("stck_bsop_date")] + nv = sorted({str(b["stck_bsop_date"]): b for b in nv}.values(), + key=lambda b: str(b["stck_bsop_date"])) + if len(nv) >= EXIT_RSI_HISTORY_MIN_BARS: + return nv, "naver" + best, best_src = nv, "naver" + except Exception as e: + warn_throttled( + logger, "exit_service:history_naver", + f"[SwingExit] {stock_code} 청산 RSI용 네이버 일봉 조회 실패(KIS 기간 조회로): " + f"{type(e).__name__}: {e}", + ) + + try: + src_api = korea_investment_api + _delegate = getattr(korea_investment_api, "_quote_delegate", None) + if callable(_delegate): + _d = _delegate() + if _d is not None: + src_api = _d + end = today_kst() + start = end - timedelta(days=_EXIT_HISTORY_KIS_CALENDAR_DAYS) + rows = await asyncio.wait_for( + src_api.get_daily_ohlcv_range( + stock_code, start.strftime("%Y%m%d"), end.strftime("%Y%m%d"), + ), + timeout=_EXIT_HISTORY_FETCH_TIMEOUT_SEC, + ) + rows = [r for r in (rows or []) if isinstance(r, dict) and r.get("stck_bsop_date")] + kis = sorted({str(r["stck_bsop_date"]): r for r in rows}.values(), + key=lambda r: str(r["stck_bsop_date"])) + if len(kis) > len(best): + best, best_src = kis, "kis_range" + except Exception as e: + warn_throttled( + logger, "exit_service:history_kis_range", + f"[SwingExit] {stock_code} 청산 RSI용 KIS 기간 일봉 조회 실패: {type(e).__name__}: {e}", + ) + return best, best_src + + +async def _load_exit_history_v2(stock_code: str, day: str) -> None: + """백그라운드: 장기 일봉을 받아 캐시에 넣는다. 예외를 밖으로 내지 않는다.""" + try: + bars, src = await _fetch_exit_history_bars_v2(stock_code) + except Exception as e: # 방어 — 헬퍼는 예외를 던지지 않도록 작성됨 + logger.warning(f"[SwingExit] {stock_code} 청산 RSI 이력 로드 예외: {type(e).__name__}: {e}") + bars, src = [], "" + if bars: + _exit_history_cache[stock_code] = {"date": day, "bars": bars, "src": src, "failed_at": None} + else: + _exit_history_cache[stock_code] = { + "date": day, "bars": None, "src": "", "failed_at": time.monotonic(), + } + + +async def _get_exit_history_v2(stock_code: str) -> "list | None": + """오늘(KST) 캐시된 장기 일봉. 없으면 백그라운드 로드를 시작하고 최대 EXIT_HISTORY_WAIT_SEC 대기.""" + from app.core.timeutil import today_kst + day = today_kst().strftime("%Y%m%d") + ent = _exit_history_cache.get(stock_code) + if ent and ent.get("date") == day: + if ent.get("bars"): + return ent["bars"] + fa = ent.get("failed_at") + if fa is not None and time.monotonic() - fa < _EXIT_HISTORY_RETRY_SEC: + return None + # 날짜가 바뀐 캐시는 정리(무한 증가 방지) + for k in [k for k, v in _exit_history_cache.items() if v.get("date") != day]: + _exit_history_cache.pop(k, None) + + task = _exit_history_tasks.get(stock_code) + if task is None or task.done(): + from app.core.background_tasks import spawn_tracked_v2 + task = spawn_tracked_v2(_load_exit_history_v2(stock_code, day)) + _exit_history_tasks[stock_code] = task + task.add_done_callback( + lambda t, c=stock_code: _exit_history_tasks.pop(c, None) + if _exit_history_tasks.get(c) is t else None + ) + try: + await asyncio.wait_for(asyncio.shield(task), timeout=EXIT_HISTORY_WAIT_SEC) + except asyncio.TimeoutError: + return None # 이번 사이클은 30봉으로 — 다음 사이클에 사용 + except Exception as e: + logger.warning(f"[SwingExit] {stock_code} 청산 RSI 이력 대기 예외: {type(e).__name__}: {e}") + return None + ent = _exit_history_cache.get(stock_code) + if ent and ent.get("date") == day and ent.get("bars"): + return ent["bars"] + return None + + +def _merge_exit_bars_v2(history: "list | None", recent: list) -> "list | None": + """장기 이력 + 최근 KIS 봉(둘 다 오래된 → 최신)을 날짜로 병합. 검증 실패 시 None. + + - 최근 봉이 겹치는 날짜를 덮어쓴다(최근 30봉·오늘 봉은 기존 출처 그대로). + - 정합 검증: 최근 봉의 **마지막(진행 중일 수 있는) 봉을 제외한** 겹치는 날짜가 1개 이상이고, + 그 종가가 모두 _EXIT_HISTORY_CLOSE_TOL 이내여야 한다(수정주가 차이·소스 불일치 차단). + - 이력에 최근 봉의 첫 날짜보다 이른 봉이 없으면(이득 없음) None. + """ + if not history or not recent: + return None + rec = {} + for b in recent: + d = str(b.get("stck_bsop_date") or "") + if len(d) != 8: + return None # 날짜 없는 최근 봉 — 병합 불가 + rec[d] = b + rec_dates = sorted(rec) + last_recent = rec_dates[-1] + hist = {} + for b in history: + d = str(b.get("stck_bsop_date") or "") + if len(d) == 8 and d <= last_recent: # 최근 봉보다 미래 날짜는 무시 + hist[d] = b + overlap = [d for d in rec_dates[:-1] if d in hist] + if not overlap: + return None + for d in overlap: + ch, cr = _bar_close_v2(hist[d]), _bar_close_v2(rec[d]) + if ch is None or cr is None: + return None + if abs(ch - cr) / cr > _EXIT_HISTORY_CLOSE_TOL: + return None + older = [hist[d] for d in sorted(hist) if d < rec_dates[0]] + if not older: + return None + merged = older + [rec[d] for d in rec_dates] + if any(_bar_close_v2(b) is None for b in older): + return None + return merged + + +async def _exit_rsi_v2(stock_code: str, recent_bars: list, closes_recent: list): + """청산 판정용 RSI. 장기 이력 병합에 성공하면 그 RSI, 아니면 기존 30봉 RSI. + + 예외를 던지지 않는다 — 실패 시 기존 값(closes_recent 기준)으로 폴백. + """ + rsi_recent = calculate_rsi(closes_recent) + # 날짜 없는 최근 봉은 병합할 수 없다 — 이력 조회 자체를 하지 않는다(불필요한 외부 호출 방지). + if not recent_bars or any( + not isinstance(b, dict) or len(str(b.get("stck_bsop_date") or "")) != 8 + for b in recent_bars + ): + _exit_rsi_source_counts["kis30"] = _exit_rsi_source_counts.get("kis30", 0) + 1 + return rsi_recent + try: + history = await _get_exit_history_v2(stock_code) + merged = _merge_exit_bars_v2(history, recent_bars) + if merged is None or len(merged) <= len(closes_recent): + if history: + warn_throttled( + logger, "exit_service:history_merge", + f"[SwingExit] {stock_code} 청산 RSI 장기 이력 병합 불가(겹침/종가 불일치) — " + f"KIS {len(closes_recent)}봉 RSI 사용", + ) + _exit_rsi_source_counts["kis30"] = _exit_rsi_source_counts.get("kis30", 0) + 1 + return rsi_recent + if len(merged) < EXIT_RSI_HISTORY_MIN_BARS: + warn_throttled( + logger, "exit_service:history_short", + f"[SwingExit] {stock_code} 청산 RSI 이력 {len(merged)}봉 " + f"(<{EXIT_RSI_HISTORY_MIN_BARS}) — 워밍업 부족 가능", + ) + # 최근 봉은 기존 경로와 같은 변환(float(stck_clpr, 0))을 쓴다 — 이력 봉은 위에서 검증됨. + rsi_full = calculate_rsi([float(b.get("stck_clpr", 0)) for b in merged]) + if rsi_full is None: + _exit_rsi_source_counts["kis30"] = _exit_rsi_source_counts.get("kis30", 0) + 1 + return rsi_recent + _exit_rsi_source_counts["merged"] = _exit_rsi_source_counts.get("merged", 0) + 1 + return rsi_full + except Exception as e: + warn_throttled( + logger, "exit_service:history_rsi", + f"[SwingExit] {stock_code} 청산 RSI 장기 이력 계산 실패 — 30봉 RSI 사용: " + f"{type(e).__name__}: {e}", + ) + _exit_rsi_source_counts["kis30"] = _exit_rsi_source_counts.get("kis30", 0) + 1 + return rsi_recent + + # [H-1 6회차] 직전 정상 지표 경로의 ATR — (atr, monotonic, KST wall). 대체가 손절 판정용. _last_atr: dict[str, tuple] = {} @@ -499,6 +1261,104 @@ def _fallback_price_v3(stock_code: str, holding, entry_at) -> "float | None": return candidates[0][1] +def _nonstale_fallback_stop_pct_v2(stock_code: str, atr, price: float, entry_at) -> float: + """실시세는 받았지만 지표 판정이 불가한 경우(일봉 조회 실패·20봉 미만·ATR 산출 실패)의 손절률. + + [하드닝 R1 2026-10-09] 이전엔 ATR 이 None 이면 `_fallback_stop_loss_pct_v2(None)` → + 일반 손절률(-7%)로 판정돼, 고변동 종목(지표 경로 손절선 -7.5%~-10%)이 차트 API 1회 실패로 + 오손절됐다(대체가 경로는 H-1 6회차에서 이미 캐시 ATR/느슨한 쪽으로 고쳐져 있어 손절 기준이 + "API 성공 여부"에 따라 달라졌다). 이제 규칙을 대체가 경로와 통일한다: + - 이번 주기 ATR 이 유효(>0)하면 그 ATR 로 판정(기존 동작) + - 없으면 `_stale_fallback_stop_pct_v2` — 신선·진입 이후의 캐시 ATR, 그것도 없으면 + 일반·고변동 중 느슨한 손절률 + 반환값은 항상 음수(부호 가드는 각 하위 함수가 try 밖에서 건다). + """ + try: + _a = float(atr) if atr is not None else 0.0 + except (TypeError, ValueError): + _a = 0.0 + if _a > 0: + return _fallback_stop_loss_pct_v2(_a, price, stock_code) + return _stale_fallback_stop_pct_v2(stock_code, price, entry_at) + + +async def _prior_position_realized_net_v2(db: AsyncSession, stock_code: str, active_trade, market): + """이 포지션에서 **이번 레그 이전에** 실현된 순손익 합계(원), 판단 불가면 None. + + [하드닝 R1 2026-10-09] 전량 청산 시 loss_tracker 승패를 마지막 레그의 gross 로 판정해, + 1차 익절(+) 후 잔량 소폭 손실 청산이 '패'로 집계됐다(449450: +3,900·+4,125 → −410). + 포지션 단위 누적 순손익 = (진입 이후 비-DT COMPLETED SELL 의 순손익 합) + 이번 레그 순손익. + - 진입 시각 = `_resolve_time_stop_entry_v3` 기준(동기화·시딩 행은 원 BUY 일자) + - commission 이 NULL 인 과거 행(비용 전 gross 기록)은 기록값에서 진입가를 역산해 + 왕복 비용을 빼 순손익으로 환산한다 + SELECT 전용(commit 없음) — DB-READ-EXCEPTION: CLAUDE.md §3.2 ③-b 스윙 스케줄러 exit_session. + """ + if active_trade is None: + return None + try: + basis = await _resolve_time_stop_entry_v3(db, stock_code, active_trade) + lower = getattr(basis, "created_at", None) + if lower is None: + return None + from app.models.models import TradeHistory + from app.services.db_worker import not_dt_strategy_clause + res = await db.execute( + select( + TradeHistory.net_profit_loss, TradeHistory.commission, + TradeHistory.total_amount, TradeHistory.executed_quantity, + TradeHistory.order_quantity, + ).where( + TradeHistory.stock_code == stock_code, + TradeHistory.order_type == "SELL", + TradeHistory.status == "COMPLETED", + TradeHistory.created_at >= lower, + not_dt_strategy_clause(TradeHistory.strategy_type), + ) + ) + rows = res.all() + except Exception as e: + logger.warning( + f"[SwingExit] {stock_code} 포지션 누적 실현손익 조회 실패 — 이번 레그 순손익만으로 " + f"승패 판정: {type(e).__name__}: {e}" + ) + return None + total = 0.0 + try: + for r in rows: + npl, comm, amt, eq, oq = r[0], r[1], r[2], r[3], r[4] + if npl is None: + continue + npl = float(npl) + if comm is not None: + total += npl + continue + q = int(eq if eq is not None else (oq or 0)) + if q <= 0 or not amt or float(amt) <= 0: + total += npl + continue + p = float(amt) / q + entry = p - npl / q + if entry <= 0: + total += npl + continue + total += float(_swing_net_pnl_v2(entry, p, q, market)["net_profit_loss"]) + except Exception as e: + logger.warning( + f"[SwingExit] {stock_code} 포지션 누적 실현손익 환산 실패 — 이번 레그 순손익만으로 " + f"승패 판정: {type(e).__name__}: {e}" + ) + return None + return total + + +def _position_is_loss_v2(prior_realized_net, leg_net) -> bool: + """전량 청산 승패 — 포지션 누적 순손익(이전 레그 합 + 이번 레그) < 0. 이전 합을 모르면 레그만.""" + total = float(leg_net) + if prior_realized_net is not None: + total += float(prior_realized_net) + return total < 0 + + def _fallback_stop_loss_pct_v2(atr, latest_price: float, stock_code: str = "") -> float: """폴백 손절률(음수 %). 해석 실패 시 config 하드 바닥값 -abs(STOP_LOSS_PERCENT). @@ -596,6 +1456,153 @@ async def _resolve_time_stop_entry_v2(db: AsyncSession, stock_code: str, active_ return SimpleNamespace(created_at=_orig) +def _swing_tax_market_v2(market) -> str: + """스윙 매도 거래세 시장 구분 — 체결 확인(_schedule_swing_sell_fill_check_v2)과 같은 매핑.""" + return "KOSDAQ" if str(market or "").upper() in ("KOSDAQ", "KQ") else "KOSPI" + + +def _swing_net_pnl_v2( + entry_price: float, exit_price: float, quantity: int, market, domestic: bool = True, +) -> dict: + """[2026-10-09 R1] 스윙 실현손익(비용 차감) — CLAUDE.md §5.1 "손익 판정은 비용 차감 순손익". + + 비용 정의는 trade_cost_core(= trade_helpers.calculate_trade_costs, DT dt_net_pnl 과 동일): + 매수·매도 수수료(KR_COMMISSION_RATE) + 매도 거래세(국내만, KOSPI/KOSDAQ 세율). + 이전엔 스윙 전량 청산의 loss_tracker 판정과 TradeHistory.net_profit_loss 가 총손익이라 + +0.1% 청산이 스윙에선 승, DT 에선 패로 집계됐다(3연패 밴이 경로별로 다르게 발동). + 반환 키: gross_profit_loss / total_cost / net_profit_loss / net_profit_loss_rate + """ + from app.services.trade_cost_core import net_realized_pnl + if not domestic: + tax_rate = 0.0 + elif _swing_tax_market_v2(market) == "KOSDAQ": + tax_rate = float(settings.KR_SELL_TAX_RATE_KOSDAQ) + else: + tax_rate = float(settings.KR_SELL_TAX_RATE_KOSPI) + return net_realized_pnl( + float(entry_price), float(exit_price), int(quantity), + float(settings.KR_COMMISSION_RATE), tax_rate, + ) + + +_SEEDED_ENTRY_REASON_PREFIXES = ("동기화 유입", "외부 유입 포지션") +_SEEDED_STRATEGY_NAMES = ("synced",) + + +def _is_seeded_active_trade_v2(active_trade) -> bool: + """created_at 이 원 진입일이 아닐 수 있는 행(동기화·시딩·부분매도 자동 생성).""" + _name = getattr(active_trade, "strategy_name", None) or "" + _reason = getattr(active_trade, "entry_reason", None) or "" + return _name in _SEEDED_STRATEGY_NAMES or _reason.startswith(_SEEDED_ENTRY_REASON_PREFIXES) + + +async def _resolve_time_stop_entry_v3(db: AsyncSession, stock_code: str, active_trade): + """시간손절 기준 진입일 보정 v3 — v2 의 인식 범위·하한 결함 보완. + + [하드닝 R1 2026-10-09] + (1) 인식: v2 는 entry_reason '동기화 유입…' 만 보정했다. sync_portfolio 시딩 행은 + strategy_name='synced' / entry_reason '외부 유입 포지션 — 동기화 시 자동 등록…' 이라 + 항상 created_at(재시딩 시각)이 진입일로 쓰여 20거래일 시계가 재시딩마다 0 이 됐다 + (181710: BUY 9/23, synced created_at 10/6). + (2) 하한: v2 의 "closed ActiveTrade 최신 updated_at" 은 sync 레이스로 닫힌 swing 행 + (실제 매도 없음)의 updated_at 이 되어 원 BUY 를 범위에서 뺐다. v3 는 TradeHistory + (비-DT·COMPLETED·created_at ≤ 행 created_at)를 시간순으로 누적해 수량이 0 이하로 + 떨어진 **마지막 SELL** 을 마지막 전량 청산으로 본다. + 범위 안 **가장 최근** BUY 일자를 쓴다(실제 최초 진입보다 이르지 않다 → 조기 청산 없음). + 한계: TradeHistory 에 없는 외부 매도가 있었다면 청산점이 잡히지 않아 그 이전 BUY 가 쓰일 수 + 있다(그래도 시간손절은 '수익 < 최소수익' 일 때만 발동). + BUY 없음·조회 실패·비교 불가면 기존 created_at. SELECT 전용(commit 없음) — + DB-READ-EXCEPTION: CLAUDE.md §3.2 ③-b 스윙 스케줄러 exit_session. + """ + if active_trade is None: + return None + _created = getattr(active_trade, "created_at", None) + if _created is None or not _is_seeded_active_trade_v2(active_trade): + return active_trade + _orig = None + try: + from app.models.models import TradeHistory + from app.services.db_worker import not_dt_strategy_clause + _res = await db.execute( + select( + TradeHistory.order_type, TradeHistory.executed_quantity, + TradeHistory.order_quantity, TradeHistory.created_at, + ).where( + TradeHistory.stock_code == stock_code, + TradeHistory.order_type.in_(("BUY", "SELL")), + TradeHistory.status == "COMPLETED", + TradeHistory.created_at <= _created, + not_dt_strategy_clause(TradeHistory.strategy_type), + ).order_by(TradeHistory.created_at.asc(), TradeHistory.id.asc()) + ) + running = 0 + for _ot, _eq, _oq, _ts in _res.all(): + _q = int(_eq if _eq is not None else (_oq or 0)) + if _ot == "BUY": + running += _q + _orig = _ts + else: + running -= _q + if running <= 0: + running = 0 + _orig = None # 전량 청산 — 그 이전 BUY 는 이 포지션의 진입이 아니다 + except Exception as e: + warn_throttled( + logger, "exit_service:time_stop_entry_v3", + f"[SwingExit] 시간손절 원 진입일 조회 실패 ({stock_code}) — created_at 사용: " + f"{type(e).__name__}: {e}", + ) + _orig = None + if _orig is None or not hasattr(_orig, "date"): + return active_trade + try: + _is_earlier = _orig < _created + except TypeError as e: + warn_throttled( + logger, "exit_service:time_stop_entry_cmp", + f"[SwingExit] 시간손절 원 진입일 비교 실패 ({stock_code}) — created_at 사용: {e}", + ) + return active_trade + if not _is_earlier: + return active_trade + from types import SimpleNamespace + return SimpleNamespace(created_at=_orig) + + +def _schedule_swing_sell_fill_check_v2( + order_id: str, stock_code: str, stock_name: str, sell_qty: int, + ref_price: float, entry_price: float, market: str | None, +) -> None: + """[2026-10-08] 스윙 시장가 매도의 체결 확인을 백그라운드로 예약 (청산 루프 비차단). + + trade_executor._verify_and_sync_execution_v3 를 재사용한다 — 체결가·체결수량·total_amount· + 순손익(수수료·세금 차감)을 실제 체결 기준으로 갱신한다. 미체결 자동 취소는 하지 않는다 + (시장가 손절을 취소하면 청산이 사라진다). + """ + from app.services.trade_executor import _verify_and_sync_execution_v3, spawn_tracked_task_v2 + _mkt = _swing_tax_market_v2(market) + try: + asyncio.get_running_loop() # 루프 없으면 코루틴을 만들기 전에 RuntimeError + # [하드닝 R2 2026-10-09] bare create_task → spawn_tracked_task_v2: 강참조 보관(GC 로 중도 + # 소멸 방지) + 종료 drain(drain_tracked_tasks_v2) 대상. 이전엔 반환값을 버려 배포 직전 + # 매도의 체결가·순손익 보정이 유실될 수 있었다. + spawn_tracked_task_v2( + _verify_and_sync_execution_v3( + order_id, stock_code, stock_name, int(sell_qty), float(ref_price), "SELL", + True, "KRX", 0, delay=3, + sell_pnl_basis={ + "entry_price": float(entry_price), + "market": _mkt, + "strategy_type": "swing", + "net_rate": False, + }, + ), + name=f"swing_sell_fill:{stock_code}", + ) + except RuntimeError as e: + logger.warning(f"[SwingExit] {stock_code} 체결 확인 예약 실패: {e}") + + def _time_stop_signal_v2(active_trade, latest_price: float, avg_price: float): """ActiveTrade.created_at(KST naive) 기준 시간손절 신호(ExitSignal) 또는 None. @@ -659,10 +1666,31 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: global _empty_holdings_streak executed_exits = [] + # [하드닝 R2 2026-10-09] 재기동 전 결과 불명 매도 대기 복원(최초 성공 1회). 일시 장애로 복원 + # 실패면 이 주기 국내 분할 익절만 보류한다 — 가드라 try 밖(§4). 헬퍼는 예외를 내지 않는다. + _hold_partials_unrestored = await _restore_unknown_sells_v2() + + # [하드닝 R4] 실행 결과 집계(바깥 finally 에서 기록 — 스케줄러가 실패로 집계할 근거) + _run_holding_codes: list = [] + _run_errored: dict = {} + _run_fatal = None + try: # strategy_type='swing' 포지션만 조회 _now_ts = time.time() holdings = await get_swing_holdings(db) + _run_holding_codes = [str(getattr(h, "stock_code", "")) for h in (holdings or [])] + # [하드닝 R1-b] 휴면(판정 대상 밖 보유)·잔고 페이지 포화 감시 — 경보 전용, 실패해도 판정 계속 + _census = await _portfolio_census_v2(db) + try: + _apply_census_v2(_census, len(_run_holding_codes)) + except Exception as e: + logger.warning(f"[SwingExit] 보유 집계 경보 처리 실패(판정 계속): {type(e).__name__}: {e}") + # [하드닝 R2] 보유 목록 밖(전량 체결 후 sync 로 행 삭제 등)의 불명 대기 대조 — 유령 ActiveTrade 방지 + if _pending_unknown_sells: + await _reconcile_orphan_unknown_sells_v2( + {str(getattr(h, "stock_code", "")) for h in (holdings or [])} + ) if not holdings: # [F5 2026-09-28] 이 로그가 debug 였기 때문에 2026-05-15~09-21 4개월간 # 청산 판정이 사실상 멈춰 있던 것이 운영 로그에서 드러나지 않았다. @@ -721,7 +1749,9 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: f"[SwingExit] {stock_code}: 보유 {total_qty}주 중 DT {_dt_qty}주 제외 " f"→ 스윙 {quantity}주 대상" ) - avg_price = float(holding.average_price or 0) + # [2026-10-08] Portfolio 평단은 스윙+DT 혼합일 수 있다(§3.5, KIS 이동평균법이라 + # DT 청산 후에도 남는다). 손익·손절 기준은 아래에서 스윙 ActiveTrade 진입가로 정한다. + _portfolio_avg = float(holding.average_price or 0) market = getattr(holding, "market", None) or ( "KRX" if _is_domestic(stock_code) else "NASD" ) @@ -733,12 +1763,27 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: # (_fetch_live_price_v2). 실패하면 대체 가격(직전 실시세 → Portfolio 평가가) # 으로 **손절 판정만** 하고(익절·트레일링·시간손절은 낡은 가격으로 하지 않음), # 대체 가격도 없으면 error 로그(레이트리밋) 후 스킵 — 청산 우선(§8.1-11). - if avg_price <= 0: - continue # ── ActiveTrade 조회 (분할 청산 상태) ──────────────────── # [H-1 2026-10-02] 대체 가격의 "진입 이전 값 거부"에 진입 시각이 필요해 현재가 # 조회 앞으로 옮겼다(조회 자체는 동일). active_trade = await get_swing_active_trade(db, stock_code) + avg_price, _entry_basis = resolve_swing_entry_price_v2( + getattr(active_trade, "entry_price", None) if active_trade is not None else None, + _portfolio_avg, + stock_code, + ) + if avg_price <= 0: + continue + if abs(avg_price - _portfolio_avg) >= 0.01: + from app.core.log_throttle import should_log as _should_log_ep + _ep_ok, _ = _should_log_ep(f"exit_service:entry_basis:{stock_code}") + else: + _ep_ok = False + if _ep_ok: + logger.info( + f"[SwingExit] {stock_code}: 스윙 진입가 {avg_price:,.2f} (기준={_entry_basis}, " + f"Portfolio 평단 {_portfolio_avg:,.2f})" + ) _live = await _fetch_live_price_v2(stock_code, market, domestic) _price_stale = _live is None @@ -834,7 +1879,11 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)] atr = calculate_atr(highs, lows, closes) - rsi = calculate_rsi(closes) + # [하드닝 R4] RSI 만 장기 이력 병합으로 워밍업(실패 시 30봉 RSI 그대로). + # ATR(최근 14 TR)·BB(최근 20봉)는 30봉으로 이미 정확하다. + rsi = await _exit_rsi_v2( + stock_code, list(reversed(chart_data)), closes, + ) bb = calculate_bollinger(closes) _fallback_atr = atr _remember_atr_v2(stock_code, atr) @@ -899,7 +1948,8 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: # (예외 시 기존 exit_signal 유지 — 청산 우선 §8.1-11). _ts_signal = None try: - _ts_basis = await _resolve_time_stop_entry_v2(db, stock_code, active_trade) + # [하드닝 R1] v3: 'synced'·'외부 유입' 시딩 행 인식 + TradeHistory 기반 전량청산 하한 + _ts_basis = await _resolve_time_stop_entry_v3(db, stock_code, active_trade) _ts_signal = _time_stop_signal_v2(_ts_basis, latest_price, avg_price) except Exception as e: logger.warning( @@ -927,6 +1977,14 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: stock_code, latest_price, getattr(active_trade, "created_at", None) if active_trade else None, ) + elif domestic: + # [하드닝 R1 2026-10-09] 실시세는 있으나 일봉/지표 조회 실패·20봉 미만 — + # 대체가 경로와 같은 규칙(이번 ATR → 신선·진입 이후 캐시 ATR → 느슨한 쪽). + # 이전엔 ATR=None 으로 일반 손절(-7%)이 고변동 종목에 적용됐다. + _fb_sl_pct = _nonstale_fallback_stop_pct_v2( + stock_code, _fallback_atr, latest_price, + getattr(active_trade, "created_at", None) if active_trade else None, + ) else: _fb_sl_pct = _fallback_stop_loss_pct_v2(_fallback_atr, latest_price, stock_code) profit_rate = ((latest_price - avg_price) / avg_price) * 100 @@ -944,11 +2002,35 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: # [2026-09-23] 상태 저장(remaining_pct·Portfolio)이 db_worker 큐 경유라 # 큐 지연·DLQ 시 다음 사이클이 옛 값을 읽어 같은 분할매도를 반복한다. # 인메모리 쿨다운으로 직전 청산 직후 재매도를 차단한다. + # [하드닝 R8 2026-10-10] 쿨다운은 분할(sell_pct<1.0) 신호에만 적용한다. 국내 전량형 + # 신호(손절·시간손절·폴백 손절·전량 익절)는 통과 — 이전엔 신호 종류를 보지 않아 + # KIS 상한에 걸려 일부만 팔린 손절의 잔량, 1차 익절 직후 급락의 손절이 최소 + # EXIT_COOLDOWN_SEC 동안 보류됐다(§8.1-11). 과다매도는 아래 매도 직전 KIS 실보유 + # 상한(C-1: min(주문가능, 보유) − DT)이 막는다. 해외는 그 상한이 없어 기존대로 쿨다운. + # 판정은 try 밖(§4). _last_exit = _recent_exits.get(stock_code) - if _last_exit is not None and (_now_ts - _last_exit) < EXIT_COOLDOWN_SEC: + if (_last_exit is not None and (_now_ts - _last_exit) < EXIT_COOLDOWN_SEC + and _exit_cooldown_applies_v2(exit_signal, domestic)): logger.info( f"[SwingExit] {stock_code}: 직전 청산 {int(_now_ts - _last_exit)}초 전 " - f"— 쿨다운({EXIT_COOLDOWN_SEC}초) 중 스킵" + f"— 쿨다운({EXIT_COOLDOWN_SEC}초) 중 분할 신호 스킵 ({exit_signal.reason})" + ) + continue + if _last_exit is not None and (_now_ts - _last_exit) < EXIT_COOLDOWN_SEC: + logger.warning( + f"[SwingExit] {stock_code}: 직전 청산 {int(_now_ts - _last_exit)}초 전이지만 " + f"전량형 신호({exit_signal.reason}) — 쿨다운({EXIT_COOLDOWN_SEC}초) 우회, " + f"KIS 실보유 상한으로 수량 결정" + ) + + # [하드닝 R2 2026-10-09] 결과 불명 대기 복원 실패 주기: 국내 분할(부분) 익절 보류. + # 복원 못 한 불명 체결이 있으면 remaining_pct 가 낡아 같은 단계가 재발동할 수 있다. + # 손절·전량 청산은 진행(청산 우선, 수량 상한은 매도 직전 KIS 실보유). + if (_hold_partials_unrestored and domestic + and float(getattr(exit_signal, "sell_pct", 1.0) or 1.0) < 1.0): + logger.warning( + f"[SwingExit] {stock_code}: 결과 불명 매도 대기 복원 실패 — 분할 익절 " + f"({exit_signal.reason}) 이 주기 보류" ) continue @@ -980,40 +2062,80 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: # 인메모리 보유분. Portfolio 수량(sync 전 낡은 값)이 부풀어 있어도 DT 물량은 # 팔 수 없다. 조회 실패면 보류, 상한 0 이면 주문하지 않는다. _pre_hldg = None + # [하드닝 R3 2026-10-09] 스윙 보유 기준(_swing_basis)과 이번 주문 상한(_cap)을 분리한다. + # - 주문 상한은 기존대로 min(주문가능, 보유) − DT 를 유지한다. 주문가능에서 빠진 대기 + # 매도분(보유 − 주문가능)이 DT 것인지 스윙 것(결과 불명 매도·지정가 익절 미체결)인지 + # 여기서 구분할 수 없고, 스윙 것인데 보유 − DT 로 상한을 잡으면 두 주문이 모두 체결될 때 + # DT 물량까지 팔린다(과다매도). 대기분이 DT 것이면 과소 매도에 그치고 잔량은 다음 + # 주기에 다시 판정된다 — 과다매도를 막는 쪽을 택한다. + # - '전량 청산' 판정은 상한 적용 전 _swing_basis = min(장부 스윙, 보유 − DT) 대비. + # 상한 때문에 일부만 판 경우는 분할 매도처럼 잔량·remaining_pct 만 갱신하고 + # ActiveTrade close·loss_tracker·closed_today 는 하지 않는다(스윙 잔량 0 일 때만). + # 장부가 부풀어 있으면(sync 전) 보유 − DT 가 기준이 되어 KIS 스윙 잔량 0 이면 전량 청산. + _swing_basis = quantity + _stage_sell_pct = float(sell_pct) if domestic: if _kis_snapshot is None: _kis_snapshot = await _presell_kis_holding_v2(stock_code) if _kis_snapshot is None: continue _kis_hldg, _kis_sellable = _kis_snapshot - _cap = max(0, min(_kis_sellable, _kis_hldg) - max(0, int(_dt_qty or 0))) + _dt_held = max(0, int(_dt_qty or 0)) + _kis_swing_hldg = max(0, _kis_hldg - _dt_held) + _cap = max(0, min(_kis_sellable, _kis_hldg) - _dt_held) if _cap <= 0: logger.warning( f"[SwingExit] {stock_code}: KIS 기준 스윙 매도 가능 0 (보유 {_kis_hldg}, " f"주문가능 {_kis_sellable}, DT {_dt_qty}) — 주문 안 함" ) continue - if _cap < quantity: + if _kis_swing_hldg < quantity: logger.warning( - f"[SwingExit] {stock_code}: 장부 스윙 {quantity}주 > KIS 기준 {_cap}주 — " - f"KIS 기준으로 상한" + f"[SwingExit] {stock_code}: 장부 스윙 {quantity}주 > KIS 보유 기준 " + f"{_kis_swing_hldg}주 (보유 {_kis_hldg} − DT {_dt_held}) — KIS 기준으로 상한" ) - quantity = _cap + _swing_basis = _kis_swing_hldg total_qty = _kis_hldg - if sell_qty > quantity: - sell_qty = quantity + _intended_sell_qty = sell_qty + if sell_qty > _cap: + sell_qty = _cap + if sell_qty < min(_intended_sell_qty, _swing_basis): + logger.warning( + f"[SwingExit] {stock_code}: KIS 상한 {_cap}주(주문가능 {_kis_sellable}, " + f"보유 {_kis_hldg}, DT {_dt_held})로 {_intended_sell_qty}→{sell_qty}주만 매도 " + f"(스윙 보유 {_swing_basis}주, 미체결 주문 잔존 추정) — 분할 처리, 잔량은 다음 주기 재판정" + ) + if _intended_sell_qty >= quantity: + # 전량형 신호(손절·전량 익절·소량 전량)가 상한에 걸림 → 분할 단계 차감 없이 + # 실제 잔량 비율만 기록해 다음 주기 같은 신호가 잔량을 다시 판정하게 한다. + _stage_sell_pct = 0.0 _pre_hldg = _kis_hldg + _is_full_close = sell_qty >= _swing_basis + _swing_left = max(0, _swing_basis - sell_qty) + # [하드닝 R1-b] 기록 사유 = 실제 매도 수량 기준(소량 전량·KIS 상한 부분매도 명시) + _exit_reason = _effective_exit_reason_v2( + exit_signal.reason, sell_pct, sell_qty, _swing_basis, original_qty, + ) # [M-4 2026-10-02] 주문 전송 ~ TradeHistory·Portfolio·ActiveTrade·loss_tracker 반영 # 전체를 asyncio.shield 로 보호한다(DT _execute_exit_impl 과 같은 패턴). 스케줄러 # 종료·취소가 safe_place_order 대기 중에 들어오면 "브로커는 체결, 장부는 미반영" → # 다음 기동 후 같은 물량 재매도가 날 수 있었다. 취소는 이 루프에만 전파되고 # shield 된 작업은 끝까지 진행된다. + # [하드닝 R1 2026-10-09] 전량 청산의 loss_tracker 승패는 포지션 단위 누적 순손익 + # (이전 분할 레그 순손익 합 + 이번 레그 순손익)으로 판정한다. 이전 레그 합은 주문 전 + # (shield 밖)에 SELECT 로 미리 구한다 — 실패하면 None → 이번 레그 순손익만으로 판정. + _prior_realized_net = None + if _is_full_close: + _prior_realized_net = await _prior_position_realized_net_v2( + db, stock_code, active_trade, market, + ) + async def _sell_and_record_v2(): # ── KIS API 매도 주문 ───────────────────────────────────── logger.info( f"[SwingExit] 청산 신호 → {stock_code} {stock_name} " - f"수량={sell_qty} 이유={exit_signal.reason}" + f"수량={sell_qty} 이유={_exit_reason}" ) # [C1] 분산락+세마포어 보호 주문 from app.services.trade_executor import safe_place_order @@ -1048,16 +2170,20 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: "since": time.time(), "price": float(latest_price), "avg_price": float(avg_price), - "swing_qty": int(quantity), - "sell_pct": float(sell_pct), + "swing_qty": int(_swing_basis), + "sell_pct": float(_stage_sell_pct), "remaining_pct": float(remaining_pct), "original_qty": int(original_qty), "strategy": _strategy, "filter_not_dt": bool(_at_filter_not_dt), "had_active_trade": active_trade is not None, "stock_name": stock_name, - "reason": exit_signal.reason, + "reason": _exit_reason, + "market": market, + "prior_realized_net": _prior_realized_net, } + # [하드닝 R2] 재기동에도 대조가 이어지도록 Redis 에 미러링(실패는 warning) + await _persist_unknown_sell_v2(stock_code, _pending_unknown_sells[stock_code]) _trigger_portfolio_sync_v2() logger.error( f"[SwingExit] 🚨 매도 주문 결과 불명 ({stock_code}, {sell_qty}주, msg_cd={_mc}): " @@ -1067,10 +2193,17 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: logger.error( f"[SwingExit] 매도 주문 실패 ({stock_code}, msg_cd={_mc}): {err} — 다음 주기 재시도" ) + # [하드닝 R4] 연속 거부 집계 + 경보(계좌 단위 거부는 즉시) — 무한 무경보 재시도 방지 + _note_swing_sell_reject_v2(stock_code, _mc, err) return + _clear_swing_sell_reject_v2(stock_code) order_id = order_resp.get("output", {}).get("ODNO", "") - profit_loss = (latest_price - avg_price) * sell_qty + # [2026-10-09 R1] profit_loss = 비용 차감 순손익(§5.1). loss_tracker 판정· + # TradeHistory.net_profit_loss 모두 이 값. 수익률은 기존 규약대로 gross + # (체결 확인 net_rate=False 와 동일 — 표시·저널 호환). + _pnl = _swing_net_pnl_v2(avg_price, latest_price, sell_qty, market, domestic) + profit_loss = _pnl["net_profit_loss"] profit_loss_rate = ((latest_price - avg_price) / avg_price) * 100 # ── DB 기록 (db_task_queue 경유) ────────────────────────── @@ -1080,12 +2213,15 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: "order_type": "SELL", "order_price": latest_price, "order_quantity": sell_qty, - "executed_price": latest_price, + # [2026-10-08] 시장가 주문 — 체결가는 아래 체결 확인이 채운다(그 전엔 NULL). + # total_amount·손익은 신호가 기준 추정치이며 확인 시 실제 체결로 덮어쓴다. + "executed_price": None, "executed_quantity": sell_qty, "total_amount": latest_price * sell_qty, "status": "COMPLETED", "order_id": order_id, - "exit_reason": exit_signal.reason, + "exit_reason": _exit_reason, + "commission": _pnl["total_cost"], "net_profit_loss": profit_loss, "profit_loss_rate": profit_loss_rate, "strategy_type": "swing", @@ -1093,6 +2229,16 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: _recent_exits[stock_code] = time.time() + # [2026-10-08] 실제 체결가·체결수량 확인 → TradeHistory 체결가·금액·순손익 보정 + # (백그라운드, db_worker 큐 경유). 이전엔 신호가(latest_price)가 그대로 체결가로 + # 남았다. 국내만 — 해외 체결조회 경로 없음(US_TRADING_ENABLED=False). + if domestic and order_id: + _schedule_swing_sell_fill_check_v2( + order_id=order_id, stock_code=stock_code, stock_name=stock_name, + sell_qty=int(sell_qty), ref_price=float(latest_price), + entry_price=float(avg_price), market=market, + ) + # 포트폴리오 화면 즉시 반영 (Portfolio 수량 차감 + 캐시) from app.services.portfolio_cache_bridge import reflect_sell # remaining_qty 는 Portfolio 행(스윙+DT 합산) 기준이어야 한다 — @@ -1102,7 +2248,8 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: ) # 전량 청산이면 ActiveTrade 종료, 분할 매도면 remaining_pct 갱신 - if sell_qty >= quantity: + # [하드닝 R3] 판정 기준은 주문 상한 적용 전 스윙 보유(_swing_basis) + if _is_full_close: # strategy_name 을 비우면 db_worker 가 '가장 최근 active 행'을 닫는다 # → 같은 종목에 DT 행이 있으면 그쪽이 닫히고 스윙 행은 영구 잔존. if _at_filter_not_dt: @@ -1131,7 +2278,7 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: # 기록한다. 매도 주수 자체는 위의 내림 공식 그대로(과매도 방지). from app.services.sizing_core import remaining_pct_after_partial_sell new_remaining = remaining_pct_after_partial_sell( - remaining_pct, sell_pct, quantity - sell_qty, original_qty + remaining_pct, _stage_sell_pct, _swing_left, original_qty ) from app.services.db_worker import enqueue_active_trade_upsert _at_data = {"stock_code": stock_code} @@ -1147,7 +2294,7 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: "remaining_pct": new_remaining, # 잔량도 함께 갱신 — 누락 시 재기동 복원이 원래 수량으로 되살아나 # 보유량보다 많이 매도한다(2026-09-23 DT LG씨엔에스 사례와 동일 패턴) - "quantity": quantity - sell_qty, + "quantity": _swing_left, "highest_since_entry": latest_price if latest_price > (trailing_high or 0) else trailing_high, }) if active_trade is None: @@ -1157,7 +2304,7 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: # 신규 생성에 필요한 필수 키를 함께 전달한다. _at_data.update({ "stock_name": stock_name, - "quantity": quantity - sell_qty, + "quantity": _swing_left, "entry_price": avg_price, "current_price": latest_price, "strategy_name": "swing", @@ -1167,12 +2314,12 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: await enqueue_active_trade_upsert(_at_data) # LossTracker 업데이트 — 전량매도 시에만 (분할매도 이중 카운트 방지) - if sell_qty >= quantity: + if _is_full_close: # [H-1 2026-10-02] 전량 청산 → 이 포지션의 실시세 캐시 폐기 # (재매수 포지션에 예전 보유분 가격이 대체가로 쓰이지 않게) _last_live_prices.pop(stock_code, None) _last_atr.pop(stock_code, None) - is_loss = profit_loss < 0 + is_loss = _position_is_loss_v2(_prior_realized_net, profit_loss) # 순손익 기준 — 왕복 비용 이내 이익도 손실(§5.1) await loss_tracker.record_trade_result_safe( stock_code=stock_code, is_loss=is_loss, @@ -1207,19 +2354,27 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: "price": latest_price, "profit_loss": profit_loss, "profit_loss_rate": profit_loss_rate, - "reason": exit_signal.reason, + "reason": _exit_reason, }) logger.info( f"[SwingExit] ✅ 청산 완료: {stock_code} {stock_name} " - f"수익률={profit_loss_rate:.1f}% 이유={exit_signal.reason}" + f"수익률={profit_loss_rate:.1f}% 이유={_exit_reason}" ) await asyncio.shield(_sell_and_record_v2()) except Exception as e: logger.error(f"[SwingExit] 종목 처리 오류 ({holding.stock_code}): {e}", exc_info=True) + # [하드닝 R4] 종목별 연속 예외 집계 + 임계 경보(이전엔 로그만 → '정상' 집계) + _err_code = str(getattr(holding, "stock_code", "")) + _run_errored[_err_code] = f"{type(e).__name__}: {e}" + _note_exit_symbol_error_v2(_err_code, _run_errored[_err_code]) except Exception as e: logger.error(f"[SwingExit] 전체 오류: {e}", exc_info=True) + # [하드닝 R4] 전체 오류는 실패로 기록 — 스케줄러가 _record_exit_failure_v2 로 집계한다 + _run_fatal = f"{type(e).__name__}: {e}" + finally: + _record_exit_run_v2(_run_holding_codes, _run_errored, _run_fatal) return executed_exits diff --git a/backend/app/services/swing/fill_sweep.py b/backend/app/services/swing/fill_sweep.py new file mode 100644 index 0000000..02d307b --- /dev/null +++ b/backend/app/services/swing/fill_sweep.py @@ -0,0 +1,452 @@ +"""체결가 미확인 TradeHistory 후속 보정 (스윙 스케줄러 사이클에서 호출). + +[2026-10-08] 운영 관측: TradeHistory #432(풍산)·#433(현대차)의 executed_price 가 주문가 +(현재가×1.005, 호가단위상 불가능한 값)로 남았다. 주문 직후의 체결 확인 +(`trade_executor._verify_and_sync_execution_v3`)이 폴링 15초 안에 체결을 못 보거나 조회가 +실패하면 그 뒤로 아무도 체결가를 채우지 않았다. + +이제 주문 경로들은 확인 전 executed_price 를 **NULL** 로 기록한다(주문가 대체 금지). 이 모듈은 +"당일 · COMPLETED · order_id 있음 · executed_price IS NULL · 일정 시간 경과" 행을 골라 +KIS 체결조회(inquire-daily-ccld)로 실제 체결가·체결수량을 확인하고 db_worker 큐로 갱신한다. + +규칙 + - DB 는 **읽기만**(호출부의 SELECT 전용 세션). 쓰기는 db_worker 큐(§3.2). + - 사이클당 KIS 조회 상한·행별 재시도 간격·최대 시도 수를 둔다(모의 초당 2건 제한 대비). + - 체결 0 · 미종결 부분체결은 갱신하지 않는다(상태 변경 없음 — 수동 확인 대상, warning). + - SELL 손익은 기록 시점 추정치에서 진입가를 역산해 체결가로 재계산한다: + p(추정가) = total_amount / order_quantity, + entry = p − (net_profit_loss + commission) / order_quantity + (모든 SELL 기록 경로가 total_amount=추정가×수량, net = (추정가−진입가)×수량 − commission + 으로 기록한다는 전제. 전제가 깨지면 손익은 갱신하지 않고 가격·금액만 갱신한다.) + - [하드닝 R1 2026-10-09] 미확인 판정은 `unconfirmed_fill_clause_v2`(NULL + 레거시 '체결가=주문가 & + slippage NULL'). BUY 의 ActiveTrade 진입가 보정은 entry_reason 으로 확정한 전략명(동등)만 — + 확정 불가면 보정하지 않고 warning. 일일 지표는 `get_fill_sweep_health_v2()`. +""" +from __future__ import annotations + +import asyncio +import logging +import time +from datetime import datetime, time as dtime, timedelta + +from sqlalchemy import select, or_, and_ + +from app.core.timeutil import now_kst +from app.models.models import ActiveTrade, TradeHistory +from app.services.utils import _is_domestic +from app.services.db_worker import DT_STRATEGY_NAMES, not_dt_strategy_clause + +logger = logging.getLogger(__name__) + +# 사이클당 최대 KIS 체결조회 수 / 주문 후 최소 경과(직후 확인 태스크와 경합 방지) / +# 같은 행 재조회 최소 간격 / 행별 최대 시도 +FILL_SWEEP_MAX_LOOKUPS_PER_CYCLE = 2 +FILL_SWEEP_MIN_AGE_SEC = 120 +FILL_SWEEP_RETRY_INTERVAL_SEC = 180 +FILL_SWEEP_MAX_ATTEMPTS = 5 + +# [하드닝 R4 2026-10-09] 종가 단일가(15:20~15:30) 주문 체결 확인. +# 동시호가 주문은 15:30 에야 체결되는데 sweep 은 장중(is_market_open, 15:30 미만)에만 돌았다 → +# 15:28 주문은 sweep 기회 0회로 executed_price 가 영구 NULL 이었다(운영 10/7 스윙 BUY 2건 15:22·15:28). +# - 장 마감 직후 창(15:30 ~ +SWING_FILL_SWEEP_POST_CLOSE_MINUTES)에 스케줄러의 장외 분기가 +# `run_post_close_fill_sweep_v2` 로 같은 sweep 을 돌린다(당일 행 그대로 — 같은 날짜 조회). +# - 동시호가 구간에 들어온 주문은 15:30 전엔 체결될 수 없으므로 조회하지 않는다(시도 횟수 보존). +_KRX_CLOSING_AUCTION_START = dtime(15, 20) +_KRX_MARKET_CLOSE = dtime(15, 30) +_DEFAULT_POST_CLOSE_MINUTES = 30 +_DEFAULT_POST_CLOSE_INTERVAL_SEC = 60.0 +_DEFAULT_POST_CLOSE_TIMEOUT_SEC = 60.0 + + +def _settings_num_v2(name: str, default: float) -> float: + try: + from app.core.config import settings + v = float(getattr(settings, name, default)) + except Exception as e: + logger.warning(f"[FillSweep] 설정 {name} 읽기 실패 — 기본값 {default}: {type(e).__name__}: {e}") + return float(default) + return v if v > 0 else float(default) + + +def _post_close_window_v2(now: datetime) -> tuple[datetime, datetime]: + start = now.replace(hour=_KRX_MARKET_CLOSE.hour, minute=_KRX_MARKET_CLOSE.minute, + second=0, microsecond=0) + minutes = _settings_num_v2("SWING_FILL_SWEEP_POST_CLOSE_MINUTES", _DEFAULT_POST_CLOSE_MINUTES) + return start, start + timedelta(minutes=minutes) + + +def post_close_sweep_active_v2(now: datetime | None = None) -> bool: + """KRX 영업일 15:30 ~ 15:30+N분(장 마감 직후 체결 확인 창) 여부.""" + from app.core.market_schedule import is_krx_holiday + now = now or now_kst() + if now.weekday() >= 5 or is_krx_holiday(now.date()): + return False + start, end = _post_close_window_v2(now) + return start <= now < end + + +def _auction_fill_pending_v2(created_at, now: datetime) -> bool: + """동시호가(15:20~) 당일 주문인데 아직 15:30 전 — 체결될 수 없으므로 조회 보류.""" + if created_at is None or not hasattr(created_at, "time"): + return False + try: + same_day = created_at.date() == now.date() + except Exception as e: + logger.warning(f"[FillSweep] created_at 비교 실패: {type(e).__name__}: {e}") + return False + return same_day and created_at.time() >= _KRX_CLOSING_AUCTION_START and now.time() < _KRX_MARKET_CLOSE + + +async def run_post_close_fill_sweep_v2(now: datetime | None = None) -> float | None: + """장 마감 직후 체결 확인 1회 (스윙 스케줄러 장외 분기에서 호출). + + 창 밖이면 None(호출부는 기존 대기 그대로). 창 안이면 sweep 을 돌리고 다음 호출까지의 + 권장 대기 상한(초)을 반환한다 — 장외 분기의 최대 1800초 대기가 창을 건너뛰지 않게. + DB 는 SELECT 전용 세션, 쓰기는 db_worker 큐(sweep 본체와 동일). 예외·시간초과는 warning + 후 흡수한다(체결가 보정은 리스크 가드가 아니다 — 다음 호출에서 재시도). + """ + now = now or now_kst() + if not post_close_sweep_active_v2(now): + return None + _, end = _post_close_window_v2(now) + interval = _settings_num_v2("SWING_FILL_SWEEP_POST_CLOSE_INTERVAL_SEC", _DEFAULT_POST_CLOSE_INTERVAL_SEC) + timeout = _settings_num_v2("SWING_FILL_SWEEP_TIMEOUT_SEC", _DEFAULT_POST_CLOSE_TIMEOUT_SEC) + try: + from app.core.database import AsyncSessionLocal + # DB-READ-EXCEPTION: 장 마감 직후 체결가 미확인 TradeHistory/ActiveTrade SELECT 전용(commit 없음, 갱신은 db_task_queue 경유) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 SELECT + async with AsyncSessionLocal() as session: + n = await asyncio.wait_for(sweep_unconfirmed_fills_v2(session), timeout=timeout) + if n: + logger.info(f"[FillSweep] 장 마감 후 체결가 보정 {n}건") + except asyncio.TimeoutError: + logger.warning(f"[FillSweep] 장 마감 후 체결가 보정 {timeout:.0f}초 초과 — 다음 호출에서 재시도") + except Exception as e: + logger.warning(f"[FillSweep] 장 마감 후 체결가 보정 실패: {type(e).__name__}: {e}") + remain = (end - now).total_seconds() + return max(15.0, min(interval, remain)) + + +# {trade_id: (attempts, last_try_monotonic)} — 당일 한정(날짜가 바뀌면 비운다) +_attempts: dict[int, tuple[int, float]] = {} +_attempts_day: str = "" +# [하드닝 R5 2026-10-09] 당일 갱신을 이미 큐잉한 TradeHistory id. 워커 반영 전(큐 지연·DLQ)에는 행이 +# 여전히 '미확인'으로 조회되므로, 재조회·재큐잉(특히 비멱등 진입가 보정)을 막기 위해 당일 제외한다. +_queued_ids: set[int] = set() + + +def _reset_if_new_day(day_iso: str) -> None: + global _attempts_day + if _attempts_day != day_iso: + _attempts.clear() + _queued_ids.clear() + _attempts_day = day_iso + + +def _exhausted_ids() -> list[int]: + return [tid for tid, (n, _) in _attempts.items() if n >= FILL_SWEEP_MAX_ATTEMPTS] + + +def _due(trade_id: int, now_mono: float) -> bool: + n, last = _attempts.get(trade_id, (0, 0.0)) + if n >= FILL_SWEEP_MAX_ATTEMPTS: + return False + return n == 0 or (now_mono - last) >= FILL_SWEEP_RETRY_INTERVAL_SEC + + +def _note_attempt(trade_id: int, now_mono: float) -> int: + n, _ = _attempts.get(trade_id, (0, 0.0)) + n += 1 + _attempts[trade_id] = (n, now_mono) + return n + + +def derive_sell_entry_price_v2(row) -> float | None: + """기록 시점 추정치에서 진입가를 역산 (순수 함수). 불가하면 None.""" + try: + q = int(row.order_quantity or 0) + amt = float(row.total_amount or 0) + if row.net_profit_loss is None or q <= 0 or amt <= 0: + return None + p = amt / q + entry = p - (float(row.net_profit_loss) + float(row.commission or 0)) / q + except (TypeError, ValueError, ZeroDivisionError): + return None + return entry if entry > 0 else None + + +# 진입가 보정 대상 TradeHistory.strategy_type — 스윙 소유만(DT 는 fill_reconciler, 수동·유입은 +# ActiveTrade 진입가를 이 주문가로 기록하지 않는다). +_SWING_BUY_STRATEGY_TYPES = (None, "", "swing") + + +# [하드닝 R1 2026-10-09] 시딩(동기화 유입) ActiveTrade 식별 — swing/exit_service 와 같은 규약. +# 시딩 행의 entry_price 는 KIS 평단이라 주문가→체결가 보정 대상이 아니다. +_SEEDED_STRATEGY_NAMES = ("synced",) +_SEEDED_ENTRY_REASON_PREFIXES = ("동기화 유입", "외부 유입 포지션") + + +def _is_seeded_active_row_v2(r) -> bool: + name = (getattr(r, "strategy_name", None) or "").strip() + reason = (getattr(r, "entry_reason", None) or "").strip() + return name in _SEEDED_STRATEGY_NAMES or reason.startswith(_SEEDED_ENTRY_REASON_PREFIXES) + + +def resolve_entry_fill_target_v2(row, active_rows) -> tuple[str | None, str]: + """BUY TradeHistory 행이 만든(갱신한) 스윙 ActiveTrade 의 strategy_name 을 확정 (순수 함수). + + [하드닝 R1 2026-10-09] 이전에는 `strategy_name_not=DT` 로 '가장 최근 비-DT active 행'을 + 보정했다. 같은 종목에 시딩('synced') 행이나 다른 듀얼 전략 행이 있으면 엉뚱한 행의 + entry_price 가 (체결가−주문가)×수량/행수량 만큼 틀어졌다(운영: 103140·005380 은 스윙 행이 + 이미 closed 이고 synced 행만 active — 구 경로라면 synced 행을 보정했다). + + 매칭: 진입 시 executor 가 TradeHistory·ActiveTrade 양쪽에 **같은 entry_reason** 을 기록한다 + (추가매수도 ActiveTrade.entry_reason 을 최신 매수 사유로 덮어쓴다). 따라서 + 1) 비-DT·비시딩·strategy_name 있는 active 행 중 entry_reason 이 같은 행을 찾고 + 2) 그 strategy_name 이 하나로 확정되며 + 3) db_worker 가 그 전략명으로 고를 행(같은 전략명 중 id 최대)이 바로 그 행일 때만 + 전략명을 돌려준다. 하나라도 어긋나면 (None, 사유) — 보정하지 않는다(확정 불가 시 보정 금지). + + Returns: + (strategy_name | None, 사유 문자열) + """ + reason = (getattr(row, "entry_reason", None) or "").strip() + if not reason: + return None, "TradeHistory.entry_reason 없음" + eligible = [ + r for r in (active_rows or []) + if (getattr(r, "strategy_name", None) or "").strip() + and (getattr(r, "strategy_name", None) or "") not in DT_STRATEGY_NAMES + and not _is_seeded_active_row_v2(r) + ] + matched = [r for r in eligible if (getattr(r, "entry_reason", None) or "").strip() == reason] + if not matched: + return None, "entry_reason 일치하는 스윙 소유 active 행 없음(청산됐거나 사유 갱신됨)" + names = {r.strategy_name for r in matched} + if len(names) != 1: + return None, f"entry_reason 일치 행의 전략명이 여러 개 {sorted(names)}" + name = next(iter(names)) + same_name = [r for r in eligible if r.strategy_name == name] + newest = max(same_name, key=lambda r: int(getattr(r, "id", 0) or 0)) + if newest not in matched: + return None, f"전략명 {name} 의 최신 active 행(id={getattr(newest, 'id', None)})이 이 매수의 행이 아님" + return name, "ok" + + +async def _load_active_rows_for_fill_v2(db, stock_code: str): + """SELECT 전용 — 호출부 세션(스윙 스케줄러 exit_session, DB-READ-EXCEPTION)으로 읽는다.""" + return (await db.execute( + select(ActiveTrade).where( + ActiveTrade.stock_code == stock_code, + ActiveTrade.status == "active", + not_dt_strategy_clause(ActiveTrade.strategy_name), + ) + )).scalars().all() + + +async def _enqueue_buy_entry_adjust_v2(row, filled_qty: int, filled_price: float, db=None) -> bool: + """스윙 BUY 체결 확인 시 **이 매수가 만든** ActiveTrade.entry_price 를 체결가로 보정 (큐 경유). + + 직후 확인(`_verify_and_sync_execution_v3`)이 이미 보정을 큐잉한 주문이면 건너뛴다(비멱등). + 대상 행은 `resolve_entry_fill_target_v2` 로 전략명을 확정해 `strategy_name`(동등) 으로 지정한다. + 확정할 수 없으면(db 없음 포함) 보정하지 않고 warning 만 남긴다 — 엉뚱한 행(시딩·타 전략)의 + 진입가를 움직이면 손절선·손익이 틀어지기 때문. + """ + from app.services.trade_executor import entry_fill_already_adjusted_v2, note_entry_fill_adjusted_v2 + from app.services.db_worker import enqueue_active_trade_upsert + + if getattr(row, "strategy_type", None) not in _SWING_BUY_STRATEGY_TYPES: + return False + if entry_fill_already_adjusted_v2(row.order_id): + return False + order_price = float(row.order_price or 0) + if order_price <= 0 or filled_price <= 0 or filled_qty <= 0: + return False + if db is None: + logger.warning( + f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} 진입가 보정 생략 — " + f"ActiveTrade 조회 세션 없음(대상 행 확정 불가)" + ) + return False + try: + active_rows = await _load_active_rows_for_fill_v2(db, row.stock_code) + except Exception as e: + logger.warning( + f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} 진입가 보정 생략 — " + f"ActiveTrade 조회 실패: {type(e).__name__}: {e}" + ) + return False + target, why = resolve_entry_fill_target_v2(row, active_rows) + if not target: + logger.warning( + f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} ActiveTrade 진입가 보정 생략 — " + f"{why} (주문가 {order_price:,.2f} → 체결가 {float(filled_price):,.2f}, 수동 확인)" + ) + return False + # [하드닝 R5 2026-10-09] 큐잉 **전에** 보정 완료로 표시한다(trade_executor 직후 확인 경로와 동일 순서). + # entry_fill_adjust 는 비멱등(entry_price += 차이)이라, 태스크가 큐에 머무는/DLQ 재시도 중인 동안 + # 다음 sweep 이 같은 주문을 다시 보정하면 진입가·손절선이 누적 이동한다. 표시 후 큐잉이 실패하면 + # 보정이 유실될 뿐(손절선 불변) 이중 이동은 일어나지 않는다 — 더 안전한 쪽. + note_entry_fill_adjusted_v2(row.order_id) + await enqueue_active_trade_upsert({ + "stock_code": row.stock_code, + "strategy_name": target, + "entry_fill_adjust": { + "order_price": order_price, + "filled_price": float(filled_price), + "filled_qty": int(filled_qty), + }, + }) + return True + + +def unconfirmed_fill_clause_v2(): + """'체결가 미확인' TradeHistory 판정식 (모니터링 지표와 sweep 이 공유하는 단일 정의).""" + return or_( + TradeHistory.executed_price.is_(None), + and_( + TradeHistory.slippage.is_(None), + TradeHistory.order_price.isnot(None), + TradeHistory.executed_price == TradeHistory.order_price, + ), + ) + + +# 일일 모니터링 지표 — 마지막 sweep 기준(프로세스 메모리, /health·로그용) +_last_stats: dict = {"day": "", "candidates": 0, "exhausted": 0, "queued": 0, "exhausted_warned": 0} + + +def get_fill_sweep_health_v2() -> dict: + """당일 체결 미확인 지표: candidates=마지막 sweep 이 본 미확인 행 수(상한 20), + exhausted=최대 시도 소진(수동 확인 대상) 행 수, queued=당일 보정 큐잉 누계.""" + return dict(_last_stats) + + +async def sweep_unconfirmed_fills_v2(db) -> int: + """체결가 미확인 행 보정. 반환 = 갱신 큐잉 건수. + + 호출부는 SELECT 전용 세션을 넘긴다(commit 없음). + """ + from app.services.korea_investment_api import korea_investment_api + from app.services.trade_executor import _fill_update_fields_v2 + from app.services.db_worker import enqueue_update_trade_history + + now = now_kst() + _reset_if_new_day(now.date().isoformat()) + day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + # created_at 은 naive KST 로 저장된다(now_kst 기본값과 동일 규약) + cutoff = now - timedelta(seconds=FILL_SWEEP_MIN_AGE_SEC) + if getattr(day_start, "tzinfo", None) is not None: + day_start = day_start.replace(tzinfo=None) + cutoff = cutoff.replace(tzinfo=None) + + if _last_stats.get("day") != now.date().isoformat(): + _last_stats.update(day=now.date().isoformat(), candidates=0, exhausted=0, queued=0, + exhausted_warned=0) + rows = (await db.execute( + select(TradeHistory) + .where( + TradeHistory.status == "COMPLETED", + # [하드닝 R1 2026-10-09] 미확인 = 체결가 NULL(현 규약) 또는 레거시 규약 + # (체결가=주문가 대체 + slippage NULL — 체결 확인은 항상 slippage 를 채운다). + # 운영 10/1~: 스윙 SELL 15/15·BUY 3/12 가 후자로 남아 손익·슬리피지가 주문가 기준이었다. + unconfirmed_fill_clause_v2(), + TradeHistory.order_id.isnot(None), + TradeHistory.order_id != "", + TradeHistory.created_at >= day_start, + TradeHistory.created_at <= cutoff, + or_(TradeHistory.currency_code.is_(None), TradeHistory.currency_code == "KRW"), + TradeHistory.order_type.in_(("BUY", "SELL")), + # DT 체결 보정은 daytrading/fill_reconciler 소관 — 이중 보정 방지(NULL 은 포함) + or_(TradeHistory.strategy_type.is_(None), + TradeHistory.strategy_type.notin_(DT_STRATEGY_NAMES)), + # 시도 소진 행이 상위 20건을 영구 점유해 새 행이 밀리지 않도록 제외 + # 큐잉 완료(워커 반영 대기) 행도 제외 — 재보정·상위 20건 점유 방지 + TradeHistory.id.notin_((_exhausted_ids() + sorted(_queued_ids)) or [-1]), + ) + .order_by(TradeHistory.created_at.asc()) + .limit(20) + )).scalars().all() + _last_stats["candidates"] = len(rows) + _last_stats["exhausted"] = len(_exhausted_ids()) + if _last_stats["exhausted"] > int(_last_stats.get("exhausted_warned", 0)): + # 소진 건수가 늘 때만 경보(2분 주기 로그 반복 방지) + _last_stats["exhausted_warned"] = _last_stats["exhausted"] + logger.warning( + f"[FillSweep] 체결 미확인 시도 소진 {_last_stats['exhausted']}건(당일) — " + f"executed_price/slippage 수동 확인 필요: ids={sorted(_exhausted_ids())[:10]}" + ) + if not rows: + return 0 + + mono = time.monotonic() + lookups = 0 + queued = 0 + for row in rows: + if lookups >= FILL_SWEEP_MAX_LOOKUPS_PER_CYCLE: + break + if row.id in _queued_ids or not _due(row.id, mono): + continue + if _auction_fill_pending_v2(getattr(row, "created_at", None), now): + continue # 종가 동시호가 주문 — 15:30 체결 전 조회는 시도만 소진한다(장 마감 후 창에서 확인) + if not _is_domestic(row.stock_code): + continue # 국내 체결조회 전용(해외는 currency_code 가 비어 있는 행이 있어 코드로도 거른다) + lookups += 1 + n = _note_attempt(row.id, mono) + info = await korea_investment_api.get_order_execution(str(row.order_id), True) + filled_qty = int((info or {}).get("filled_qty", 0) or 0) + filled_price = float((info or {}).get("filled_price", 0) or 0) + remaining = (info or {}).get("remaining_qty") + ordered = int(row.order_quantity or 0) + + if not (info or {}).get("ok"): + logger.warning( + f"[FillSweep] {row.stock_code} #{row.id} 체결조회 실패 ({n}/{FILL_SWEEP_MAX_ATTEMPTS}) " + f"order_id={row.order_id}" + ) + continue + if filled_qty <= 0 or filled_price <= 0: + logger.warning( + f"[FillSweep] {row.stock_code} #{row.id} {row.order_type} 체결 미확인 " + f"(체결 {filled_qty}주, 주문행발견={info.get('found')}, 잔량={remaining}) " + f"({n}/{FILL_SWEEP_MAX_ATTEMPTS}) — 갱신 안 함, 소진 시 수동 확인" + ) + continue + if filled_qty < ordered and remaining != 0: + logger.warning( + f"[FillSweep] {row.stock_code} #{row.id} 부분체결 {filled_qty}/{ordered}주 미종결 " + f"(잔량={remaining}) — 종결 후 재시도" + ) + continue + filled_qty = min(filled_qty, ordered) if ordered > 0 else filled_qty + + ref = float(row.order_price or 0) or (float(row.total_amount or 0) / ordered if ordered else 0.0) + slippage = ((filled_price - ref) / ref * 100) if ref > 0 else 0.0 + basis = None + if row.order_type == "SELL": + entry = derive_sell_entry_price_v2(row) + if entry is not None: + basis = { + "entry_price": entry, + "market": "KOSPI", + "is_domestic": True, + "net_rate": False, + } + else: + logger.warning( + f"[FillSweep] {row.stock_code} #{row.id} SELL 진입가 역산 불가 — 체결가·금액만 갱신" + ) + fields = _fill_update_fields_v2(row.order_type, filled_qty, filled_price, slippage, basis) + fields.pop("executed_at", None) # 실제 체결 시각을 모른다 — 기존 값 유지 + # 큐잉 전에 표시 — 이후 단계가 예외로 빠져도 같은 행을 당일 다시 보정하지 않는다 + _queued_ids.add(row.id) + _attempts.pop(row.id, None) + await enqueue_update_trade_history(row.id, fields) + if row.order_type == "BUY": + await _enqueue_buy_entry_adjust_v2(row, filled_qty, filled_price, db) + queued += 1 + _last_stats["queued"] = int(_last_stats.get("queued", 0)) + 1 + logger.info( + f"[FillSweep] {row.stock_code} #{row.id} {row.order_type} 체결 확인 — " + f"{filled_qty}주 @ {filled_price:,.2f} (기준가 {ref:,.2f}, 슬리피지 {slippage:+.3f}%)" + ) + return queued diff --git a/backend/app/services/swing/position_manager.py b/backend/app/services/swing/position_manager.py index e6877e3..293410d 100644 --- a/backend/app/services/swing/position_manager.py +++ b/backend/app/services/swing/position_manager.py @@ -44,7 +44,134 @@ async def get_swing_holdings(db: AsyncSession) -> list: Portfolio.strategy_type == STRATEGY_TYPE, ) ) - return result.scalars().all() + holdings = result.scalars().all() + # [2026-10-09 하드닝 R1] 어느 청산 엔진에도 속하지 않는 보유분 탐지(경보 전용). + # 경보는 리스크 가드가 아니므로 실패해도 청산 대상 반환을 막지 않는다(청산 우선 §8.1-11). + try: + await detect_strategy_tag_mismatch_v2(db) + except Exception as e: + logger.warning(f"[SwingPM] 전략 태그 불일치 탐지 실패(청산 대상 조회는 계속): {e}") + return holdings + + +# ── [2026-10-09 하드닝 R1] Portfolio 태그 ↔ ActiveTrade 전략명 불일치 탐지 ───────── +# 사례: 105560 3주가 Portfolio.strategy_type='daytrading' + active ActiveTrade +# strategy_name='synced'(비-DT). get_swing_holdings 는 'swing' 태그만 보므로 스윙 청산이 +# 보지 않고, DT 엔진은 인메모리 포지션만 관리하므로(당일 매수분은 EOD 청산) 잔여 수량에는 +# **손절이 걸려 있지 않다**. 자동 편입(스윙 청산 대상으로 승격)은 과다매도·소유권 규칙 +# (trade_executor._sell_ownership_block_reason_v2) 충돌 위험이 있어 하지 않고, error 경보로 +# 운영자가 KIS 실보유를 확인하게 한다. +def find_strategy_tag_mismatches_v2( + portfolio_rows, + active_rows, + dt_inmemory_qty: dict | None = None, +) -> list[dict]: + """순수 함수 — DT 태그 Portfolio 행 중 비-DT active ActiveTrade 가 걸려 있고, + DT 인메모리 수량이 보유 수량을 덮지 못하는(= 관리 주체 없는 수량이 있는) 종목. + + Args: + portfolio_rows: Portfolio 유사 객체(stock_code, quantity, strategy_type) + active_rows: status='active' ActiveTrade 유사 객체(stock_code, strategy_name, id) + dt_inmemory_qty: {stock_code: DT 인메모리 보유 수량} + + Returns: + [{stock_code, portfolio_qty, portfolio_tag, dt_inmemory_qty, unmanaged_qty, + active_trade_ids, active_strategy_names}] + """ + dt_mem = dt_inmemory_qty or {} + non_dt_active: dict[str, list] = {} + for a in active_rows or []: + name = getattr(a, "strategy_name", None) + if name in DT_STRATEGY_NAMES: + continue + non_dt_active.setdefault(getattr(a, "stock_code", None), []).append(a) + + out: list[dict] = [] + for p in portfolio_rows or []: + if getattr(p, "strategy_type", None) not in DT_STRATEGY_NAMES: + continue + try: + qty = int(getattr(p, "quantity", 0) or 0) + except (TypeError, ValueError): + qty = 0 + if qty <= 0: + continue + code = getattr(p, "stock_code", None) + rows = non_dt_active.get(code) + if not rows: + continue + try: + mem = max(0, int(dt_mem.get(code, 0) or 0)) + except (TypeError, ValueError): + mem = 0 + unmanaged = qty - min(mem, qty) + if unmanaged <= 0: + continue + out.append({ + "stock_code": code, + "portfolio_qty": qty, + "portfolio_tag": getattr(p, "strategy_type", None), + "dt_inmemory_qty": mem, + "unmanaged_qty": unmanaged, + "active_trade_ids": [getattr(r, "id", None) for r in rows], + "active_strategy_names": [getattr(r, "strategy_name", None) for r in rows], + }) + return out + + +def _dt_inmemory_qty_snapshot_v2() -> dict: + """DT 인메모리 보유 수량 스냅샷(읽기 전용). DT 상태를 못 읽으면 빈 dict — + 그 경우 DT 태그 수량 전부를 '관리 주체 없음'으로 보아 경보 쪽으로 기운다.""" + try: + from app.services.daytrading.position_manager import day_trading_state + return { + code: int((pos or {}).get("quantity", 0) or 0) + for code, pos in dict(day_trading_state.positions).items() + } + except Exception as e: + logger.warning(f"[SwingPM] DT 인메모리 수량 조회 실패 — 빈 스냅샷으로 판정: {e}") + return {} + + +async def detect_strategy_tag_mismatch_v2(db: AsyncSession) -> list[dict]: + """DB SELECT 2회(쓰기 없음) + DT 인메모리 대조 → 불일치 종목을 error 로그(종목별 레이트리밋). + + 반환값은 테스트·상태 조회용. 자동 수정은 하지 않는다(운영자 확인 필요). + """ + p_res = await db.execute( + select(Portfolio).where( + Portfolio.quantity > 0, + Portfolio.strategy_type.in_(DT_STRATEGY_NAMES), + ) + ) + p_rows = p_res.scalars().all() + if not p_rows: + return [] + codes = [getattr(p, "stock_code", None) for p in p_rows] + a_res = await db.execute( + select(ActiveTrade).where( + ActiveTrade.status == "active", + ActiveTrade.stock_code.in_(codes), + not_dt_strategy_clause(ActiveTrade.strategy_name), + ) + ) + a_rows = a_res.scalars().all() + found = find_strategy_tag_mismatches_v2(p_rows, a_rows, _dt_inmemory_qty_snapshot_v2()) + if found: + from app.core.log_throttle import should_log + for m in found: + ok, sup = should_log(f"swing_pm:tag_mismatch:{m['stock_code']}") + if not ok: + continue + logger.error( + f"[SwingPM] 🚨 {m['stock_code']}: Portfolio 태그 '{m['portfolio_tag']}' " + f"{m['portfolio_qty']}주인데 비-DT active ActiveTrade " + f"{m['active_strategy_names']}(id={m['active_trade_ids']}) 존재, DT 인메모리 " + f"{m['dt_inmemory_qty']}주 → {m['unmanaged_qty']}주가 스윙·DT 어느 청산 엔진에도 " + f"속하지 않아 손절 없음. KIS 실보유 확인 후 태그/ActiveTrade 수동 정리 필요" + + (f" (+{sup}건 억제)" if sup else "") + ) + return found async def get_swing_active_trades(db: AsyncSession) -> list: @@ -122,3 +249,138 @@ async def count_swing_positions(db: AsyncSession) -> int: ) ) return result.scalar_one() or 0 + + +# ── [2026-10-08] 스윙 전략 진입가 (Portfolio 평단 혼합 방지) ────────────────── +# Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+DT 수량·평단이 합산된다(§3.5). +# KIS pchs_avg_pric 는 이동평균법이라 DT 가 전량 매도한 뒤에도 혼합 평단이 그대로 남는다 +# (예: 스윙 5주@68,000 + DT 8주@66,900 → 67,323 — DT 청산 후에도 67,323). +# 따라서 스윙 손익·손절은 **스윙 소유 ActiveTrade.entry_price** 를 기준으로 한다. +# ActiveTrade 가 오래된 유령 행이면 진입가가 엉뚱할 수 있으므로 Portfolio 평단과의 괴리가 +# 이 비율을 넘으면 **Portfolio 평단(KIS 실제 매입 평단)** 을 쓰고 warning 을 남긴다. +# +# [2026-10-09 하드닝 R1 / 검증 1회차 V1a M-2] 이전에는 괴리 > 30% 면 "높은 값"을 썼다. +# 유령 entry 100,000 / 실평단 50,000 / 현재가 52,000 이면 -48% 로 판정돼 **허위 전량 손절** +# + loss_tracker 허위 손실(3연패 → 42일 진입 금지)이 났다. 30% 괴리는 DT 혼합 평단으로는 +# 만들어지기 어렵고(혼합 평단은 두 매입가 사이에 있다) 유령/오염 행일 가능성이 압도적이므로, +# 실제 돈이 걸린 KIS 평단을 기준으로 한다. 반대 방향(유령 entry 가 낮은 경우)도 KIS 평단이 +# 높으므로 손절이 늦춰지지 않는다 — 계좌 기준 손실이 그대로 손절선에 반영된다. +SWING_ENTRY_PRICE_MAX_DEVIATION = 0.30 + + +def resolve_swing_entry_price_v2( + active_trade_entry_price, + portfolio_avg_price, + stock_code: str = "", +) -> tuple[float, str]: + """스윙 손익·손절 판정용 진입가 (순수 함수 — DB·네트워크 없음). + + Returns: + (price, basis) — basis: + "active_trade" : 스윙 ActiveTrade.entry_price (정상) + "portfolio_avg_deviation" : 두 값 괴리 > SWING_ENTRY_PRICE_MAX_DEVIATION → + Portfolio 평단(KIS 실제 매입 평단). ActiveTrade 는 유령 의심. + (2026-10-09 이전의 "conservative_max"=max(두 값) 는 폐기 — + 유령 고가 entry 로 허위 전량 손절을 냈다) + "portfolio_avg" : ActiveTrade 진입가 없음 → Portfolio 평단(혼합 가능) 폴백 + "none" : 둘 다 없음(0) — 호출부가 판정을 건너뛴다 + """ + try: + at = float(active_trade_entry_price or 0) + except (TypeError, ValueError): + at = 0.0 + try: + pa = float(portfolio_avg_price or 0) + except (TypeError, ValueError): + pa = 0.0 + + if at > 0 and pa > 0: + dev = abs(at / pa - 1.0) + if dev <= SWING_ENTRY_PRICE_MAX_DEVIATION + 1e-9: # 부동소수 경계(정확히 30%) 포함 + return at, "active_trade" + from app.core.log_throttle import warn_throttled + warn_throttled( + logger, f"swing_entry_price:{stock_code}", + f"[SwingEntryPrice] {stock_code}: ActiveTrade 진입가 {at:,.2f} 와 Portfolio 평단 " + f"{pa:,.2f} 괴리 {dev:.1%} > {SWING_ENTRY_PRICE_MAX_DEVIATION:.0%} — 유령/오염 행 의심, " + f"KIS 실제 평단 {pa:,.2f} 사용(ActiveTrade 수동 확인 필요)" + ) + return pa, "portfolio_avg_deviation" + if at > 0: + return at, "active_trade" + if pa > 0: + return pa, "portfolio_avg" + return 0.0, "none" + + +# ── [2026-10-09 하드닝 R1] 스윙 SELL 의 출처(시스템 진입 vs 동기화 유입) 분리 ───────── +# 운영 실측: swing SELL 중 선행 스윙 BUY 가 없는 매도 35건(pnl +62,789) vs 스윙 BUY 이후 +# 매도 30건(pnl -166,885). 유입분이 sync 로 'swing' 태그를 받아 청산되면 전략 성과에 섞여 +# 시스템 진입 신호의 품질이 가려진다. 통계 API 가 쓸 수 있도록 **원장 순수량** 기준으로 +# 매도 수량을 system/inflow 로 배분하는 순수 함수를 둔다(DB·네트워크 없음). +_SELL_ORIGIN_EXCLUDED_STATUSES = frozenset({"FAILED", "CANCELLED", "CANCELED", "REJECTED"}) + + +def _row_get_v2(row, key, default=None): + if isinstance(row, dict): + return row.get(key, default) + return getattr(row, key, default) + + +def split_swing_sells_by_origin_v2(trades) -> list[dict]: + """스윙 TradeHistory 행들을 시간순으로 훑어 각 SELL 의 수량을 출처별로 나눈다. + + 규칙: 종목별 '시스템 보유' = 누적 스윙 BUY 체결수량 − 이미 system 으로 배분된 SELL 수량. + SELL 은 시스템 보유분부터 소진하고(FIFO 무관, 수량만), 초과분은 inflow(동기화 유입)로 본다. + strategy_type 이 swing(또는 NULL) 이 아닌 행, 실패·취소·거부 행은 무시한다. + + Returns: + SELL 마다 {id, stock_code, created_at, sell_qty, system_qty, inflow_qty, + origin: "system"|"inflow"|"mixed"} (입력 시간순) + """ + rows = [] + for idx, t in enumerate(trades or []): + st = _row_get_v2(t, "strategy_type") + if st not in (None, STRATEGY_TYPE): + continue + status = str(_row_get_v2(t, "status") or "").upper() + if status in _SELL_ORIGIN_EXCLUDED_STATUSES: + continue + side = str(_row_get_v2(t, "order_type") or "").upper() + if side not in ("BUY", "SELL"): + continue + q = _row_get_v2(t, "executed_quantity") + if q is None: + q = _row_get_v2(t, "order_quantity") + try: + q = int(q or 0) + except (TypeError, ValueError): + q = 0 + if q <= 0: + continue + rows.append((_row_get_v2(t, "created_at"), idx, side, q, t)) + + # created_at 없는 행은 입력 순서를 유지(idx 보조키) + rows.sort(key=lambda r: (r[0] is None, r[0] if r[0] is not None else 0, r[1])) + + held: dict[str, int] = {} + out: list[dict] = [] + for created_at, _idx, side, q, t in rows: + code = _row_get_v2(t, "stock_code") + if side == "BUY": + held[code] = held.get(code, 0) + q + continue + sys_q = min(q, held.get(code, 0)) + held[code] = held.get(code, 0) - sys_q + inflow_q = q - sys_q + origin = "system" if inflow_q == 0 else ("inflow" if sys_q == 0 else "mixed") + out.append({ + "id": _row_get_v2(t, "id"), + "stock_code": code, + "created_at": created_at, + "sell_qty": q, + "system_qty": sys_q, + "inflow_qty": inflow_q, + "origin": origin, + }) + return out diff --git a/backend/app/services/swing/scheduler.py b/backend/app/services/swing/scheduler.py index 18661aa..79fbf65 100644 --- a/backend/app/services/swing/scheduler.py +++ b/backend/app/services/swing/scheduler.py @@ -57,6 +57,940 @@ def get_mdd_update_health() -> dict: return snapshot +# ───────────────────────────────────────────────────────────────────────────── +# [하드닝 R1 2026-10-09] 단계 타임아웃 · 청산 판정 하트비트 · 텔레그램 경보 +# +# (1) 단계 상한: DT 의 _guard(2026-09-22 KIS 무응답 1시간 49분 정지 → EOD 청산 누락)와 +# 같은 목적. 단, 취소 정책을 단계별로 나눈다(과다매도 방지가 우선). +# - 체결가 보정·잔고 조회(주문 없음): wait_for 로 **취소**한다. +# - 청산 판정·MDD 축소·진입(평균회귀+듀얼) — 주문 경로: **취소하지 않는다**. +# 상한 초과 시 루프만 풀어 다음 단계로 넘어가고, 그 작업이 끝날 때까지 같은 단계를 +# 새로 시작하지 않는다. 청산은 내부 매도가 shield 라 취소해도 매도 자체는 계속되는데, +# 그 상태로 새 판정을 시작하면 같은 종목 분할 매도가 이중 실행될 수 있다. MDD 축소· +# 진입은 shield 가 없어 취소 시 "브로커 체결·장부 미반영" 후 재주문 위험이 있다. +# (2) 청산 판정 하트비트: 장중에 청산 판정이 마지막으로 정상 완료된 뒤 +# SWING_EXIT_STALE_ALERT_SEC 를 넘기면 error + 텔레그램. 개장 직후는 개장 시각을 기준으로 +# 계산해 밤사이 공백을 오경보하지 않는다. 사이클 하트비트(heartbeat.beat("swing"))도 +# 매 반복 시작마다 찍어 main.py 감시 루프가 루프 정지를 잡게 한다. +# (3) 경보: MDD fail-safe 진입 차단 진입/해제, 청산 체크 연속 실패, 단계 타임아웃. +# 같은 key 는 SWING_ALERT_REPEAT_SEC 간격으로만 재전송한다. 전송은 백그라운드 태스크라 +# 텔레그램 지연이 손절 루프를 막지 않는다. 텔레그램 미설정이면 기동 시 error 1회 + +# get_swing_scheduler_health_v2()["telegram_enabled"]=False. +# ───────────────────────────────────────────────────────────────────────────── +_DEFAULT_EXIT_STEP_TIMEOUT_SEC = 180.0 +_DEFAULT_FILL_SWEEP_TIMEOUT_SEC = 60.0 +_DEFAULT_BALANCE_TIMEOUT_SEC = 60.0 +_DEFAULT_MDD_REDUCTION_TIMEOUT_SEC = 180.0 +_DEFAULT_ENTRY_PHASE_TIMEOUT_SEC = 300.0 +_DEFAULT_EXIT_FAIL_ALERT_COUNT = 3 +_DEFAULT_EXIT_STALE_ALERT_SEC = 600.0 +_DEFAULT_ALERT_REPEAT_SEC = 1800.0 + +_STEP_TIMED_OUT = object() # 상한 초과(작업은 취소 또는 백그라운드 계속) +_STEP_SKIPPED = object() # 이전 같은 단계 작업이 아직 진행 중이라 이번 사이클 생략 + +_exit_health: dict = { + "last_success_at": None, # monotonic — 청산 판정 정상 완료 시각 + "market_open_since": None, # monotonic — 이번 장중 구간을 처음 관측한 시각 + "consecutive_failures": 0, # 예외 + 타임아웃 + "last_error": None, + "timeouts": 0, +} +_step_timeouts: dict = {} # step -> 누적 타임아웃 횟수 +_inflight_steps: dict = {} # step -> asyncio.Task (취소하지 않는 단계의 진행 중 작업) +_alert_sent_at: dict = {} # key -> monotonic 마지막 전송 시각 +_alert_tasks: set = set() # 텔레그램 백그라운드 전송 태스크 참조 유지 +_blind_prev: dict = {"active": False} +_telegram_state: dict = {"enabled": None} + +# [하드닝 R3 2026-10-09] 비취소형 단계 하드 데드라인 — 상한(timeout)의 N배를 넘겨도 끝나지 않는 +# 작업은 critical 경보 + /health degraded. 주문이 shield 로 분리된 '청산 판정'만, 진행 중인 +# shield 주문 태스크가 없을 때 강제 취소 후 다음 사이클에 재시작한다(아래 _handle_hard_deadline_v2). +_DEFAULT_STEP_HARD_DEADLINE_MULT = 5.0 +_DEFAULT_STEP_CANCEL_GRACE_SEC = 10.0 +_HARD_CANCEL_STEPS = frozenset({"청산 판정"}) +# swing/exit_service 가 asyncio.shield 로 감싸는 "주문 전송 ~ 장부 반영" 코루틴 이름. +# 이 태스크가 살아 있으면 청산 판정을 재시작하지 않는다(같은 종목 분할매도 이중 실행 방지). +_SWING_SHIELDED_ORDER_CORO_NAMES = frozenset({"_sell_and_record_v2"}) +_inflight_meta: dict = {} # step -> {"started": monotonic, "hard": 하드 데드라인(초)} +_hard_deadline_stats: dict = {"cancels": 0, "exceeded": {}} # exceeded: step -> 누적 초과 감지 횟수 + +# [하드닝 R3 2026-10-09] 텔레그램 경보 전송 결과 추적 — 실패를 '전송됨'으로 두지 않는다. +_DEFAULT_ALERT_SEND_RETRIES = 2 # 첫 시도 외 추가 시도 횟수(백그라운드) +_DEFAULT_ALERT_RETRY_BACKOFF_SEC = 5.0 # 시도 간 대기(선형 증가) +_DEFAULT_ALERT_FAIL_RETRY_SEC = 60.0 # 최종 실패 시 같은 key 재경보 허용까지의 간격 +_telegram_send_health: dict = { + "sent": 0, + "failures": 0, # 재시도까지 모두 실패한 경보 수(누계) + "consecutive_failures": 0, + "last_success_at": None, # monotonic + "last_failure_at": None, # monotonic + "last_error": None, +} + +# [하드닝 R3 2026-10-09] MDD 단계 임계 — mdd_manager.update() 가 실제로 전이하는 값과 같아야 한다. +_MDD_MANAGER_PAUSE_PCT = -10.0 # mdd_manager.update: `drawdown_pct <= -10` 리터럴 +_MDD_MANAGER_FORCE_PCT = -20.0 # mdd_manager.update: `drawdown_pct <= -20` 리터럴 +_mdd_knob_state: dict = {"mismatches": []} + + +# [하드닝 R4 2026-10-09] 스윙 튜닝 키(SWING_*_TIMEOUT_SEC, MDD_STAGE_CONFIRM_CYCLES 등)는 +# config.Settings 에 선언돼 있지 않다. Settings 는 `extra="ignore"` 라 .env/compose 에 넣어도 +# 속성이 생기지 않아 getattr 가 늘 모듈 기본값을 돌려줬다(운영자 오버라이드가 조용히 무시됨). +# 선언 전까지는 settings 속성 → 프로세스 환경변수 → Settings 의 env_file 순으로 직접 읽는다. +# config.py 에 선언되면 1순위(settings 속성)가 그대로 이긴다. +_env_file_cache: dict = {} +_undeclared_env_warned: set = set() + + +def _env_file_values_v2() -> dict: + """Settings 가 읽는 env_file(.env) 의 키/값. 실패하면 빈 dict(경고 1회).""" + try: + cfg = getattr(type(settings), "model_config", None) or {} + path = cfg.get("env_file") if isinstance(cfg, dict) else None + except Exception as e: + logger.warning(f"[Swing] settings env_file 경로 확인 실패: {e}") + return {} + if not path or isinstance(path, (list, tuple)): + return {} + key = str(path) + if key in _env_file_cache: + return _env_file_cache[key] + vals: dict = {} + try: + import os as _os + if _os.path.isfile(key): + from dotenv import dotenv_values + vals = {k: v for k, v in dotenv_values(key).items() if v is not None} + except Exception as e: + logger.warning(f"[Swing] env_file({key}) 읽기 실패 — 스윙 튜닝 키는 기본값 사용: {e}") + vals = {} + _env_file_cache[key] = vals + return vals + + +def _raw_setting_v2(name: str): + """튜닝 키 원시값. settings 속성 > os.environ > env_file. 없으면 None.""" + raw = getattr(settings, name, None) + if raw is not None: + return raw + import os as _os + src = None + if name in _os.environ: + raw, src = _os.environ.get(name), "환경변수" + else: + file_vals = _env_file_values_v2() + if name in file_vals: + raw, src = file_vals[name], "env_file" + if src is not None and name not in _undeclared_env_warned: + _undeclared_env_warned.add(name) + logger.warning( + f"[Swing] 설정 {name}={raw!r} 는 config.Settings 에 미선언 — {src}에서 직접 적용" + f"(Settings 에 선언 필요)" + ) + return raw + + +def _cfg_float_v2(name: str, default: float) -> float: + """settings 값을 양수 float 로 읽는다. 누락·비정상 값이면 기본값(상한을 끄지 않는다).""" + raw = _raw_setting_v2(name) + try: + val = float(raw) if raw is not None else float(default) + except (TypeError, ValueError): + logger.warning(f"[Swing] 설정 {name}={raw!r} 비정상 — 기본값 {default} 사용") + return float(default) + if not val > 0: + logger.warning(f"[Swing] 설정 {name}={raw!r} 는 양수가 아님 — 기본값 {default} 사용") + return float(default) + return val + + +def _now_mono_v2() -> float: + import time as _time + return _time.monotonic() + + +def _telegram_enabled_v2() -> bool: + try: + from app.services.telegram_notifier import _is_enabled + return bool(_is_enabled()) + except Exception as e: + logger.warning(f"[Swing] 텔레그램 설정 확인 실패: {type(e).__name__}: {e}") + return False + + +def _check_telegram_configured_v2() -> bool: + enabled = _telegram_enabled_v2() + _telegram_state["enabled"] = enabled + if not enabled: + logger.error( + "[Swing] 🚨 텔레그램 미설정(TELEGRAM_BOT_TOKEN/CHAT_ID) — 청산 정지·MDD 진입 차단·" + "단계 타임아웃 경보가 로그로만 남는다" + ) + return enabled + + +async def _send_system_warning_checked_v2(category: str, message: str) -> "bool | None": + """텔레그램 시스템 경고를 보내고 **전송 결과**를 돌려준다. + + True=전송 성공, False=전송 실패(비200·전송 오류), None=텔레그램 미설정(전송 시도 안 함). + telegram_notifier.notify_system_warning 은 _send_message 의 bool 을 버리므로 같은 포맷으로 + 공개 API send_message(bool 반환)를 직접 호출한다. + """ + from app.services.telegram_notifier import telegram_notifier, _is_enabled, _escape_markdown_v2 + if not _is_enabled(): + return None + esc = _escape_markdown_v2 + text = "\n".join([ + f"\U0001F6A8 *{esc('시스템 경고')}*", + "", + f"\U0001F3F7 {esc('유형')}: {esc(category)}", + f"\U0001F4DD {esc(message)}", + ]) + return bool(await telegram_notifier.send_message(text)) + + +def _note_telegram_send_result_v2(ok: bool, err: str | None = None) -> None: + h = _telegram_send_health + now = _now_mono_v2() + if ok: + h["sent"] += 1 + h["consecutive_failures"] = 0 + h["last_success_at"] = now + return + h["failures"] += 1 + h["consecutive_failures"] += 1 + h["last_failure_at"] = now + h["last_error"] = err + + +def _notify_throttled_v2(key: str, category: str, message: str, repeat_sec: float | None = None) -> bool: + """같은 key 는 repeat_sec 간격으로만 텔레그램 시스템 경고를 보낸다(백그라운드, 비차단). + + 반환: 이번 호출에서 전송을 예약했으면 True. 로그는 호출부가 남긴다. + + [하드닝 R3] 전송 결과를 확인한다. 실패(비200·예외)면 백그라운드에서 재시도하고, 끝내 + 실패하면 실패 누계를 올리고(/health) 이 key 의 억제 시각을 되돌려 짧은 간격 + (SWING_ALERT_FAIL_RETRY_SEC) 뒤 다음 호출이 다시 보내게 한다 — 실패를 '전송됨'으로 두지 않는다. + """ + interval = repeat_sec if repeat_sec is not None else _cfg_float_v2( + "SWING_ALERT_REPEAT_SEC", _DEFAULT_ALERT_REPEAT_SEC + ) + now = _now_mono_v2() + last = _alert_sent_at.get(key) + if last is not None and now - last < interval: + return False + _alert_sent_at[key] = now + stamp = now + try: + retries = _cfg_int_v2("SWING_ALERT_SEND_RETRIES", _DEFAULT_ALERT_SEND_RETRIES, minimum=0) + backoff = _cfg_float_v2("SWING_ALERT_RETRY_BACKOFF_SEC", _DEFAULT_ALERT_RETRY_BACKOFF_SEC) + fail_retry = _cfg_float_v2("SWING_ALERT_FAIL_RETRY_SEC", _DEFAULT_ALERT_FAIL_RETRY_SEC) + + async def _send(): + err = None + for attempt in range(retries + 1): + try: + ok = await _send_system_warning_checked_v2(category=category, message=message) + except Exception as e: + ok = False + err = f"{type(e).__name__}: {e}" + if ok is None: + return # 미설정 — 실패가 아니다(기동 시 error + /health telegram_enabled) + if ok: + _note_telegram_send_result_v2(True) + return + if err is None: + err = "sendMessage 실패(비200 응답 또는 전송 오류 — telegram_notifier warning 로그 참조)" + if attempt < retries: + await asyncio.sleep(backoff * (attempt + 1)) + _note_telegram_send_result_v2(False, err) + # 억제 롤백 — 다른 전송이 그 사이 key 를 갱신하지 않았을 때만 + if _alert_sent_at.get(key) == stamp: + _alert_sent_at[key] = stamp - max(0.0, interval - fail_retry) + logger.error( + f"[Swing] 🚨 텔레그램 경보 전송 실패({key}, {retries + 1}회 시도): {err} — " + f"{min(interval, fail_retry):.0f}초 뒤 재경보 허용. 원문: {message}" + ) + + task = asyncio.create_task(_send()) + _alert_tasks.add(task) + task.add_done_callback(_alert_tasks.discard) + return True + except Exception as e: + if _alert_sent_at.get(key) == stamp: + if last is None: + _alert_sent_at.pop(key, None) + else: + _alert_sent_at[key] = last + logger.warning(f"[Swing] 텔레그램 경보 예약 실패({key}): {type(e).__name__}: {e}") + return False + + +def _record_step_timeout_v2(step: str, timeout: float, cancelled: bool) -> None: + _step_timeouts[step] = _step_timeouts.get(step, 0) + 1 + how = "취소" if cancelled else "백그라운드 계속(취소 안 함 — 주문 중복 방지), 끝날 때까지 이 단계 생략" + msg = f"스윙 '{step}' {timeout:.0f}초 초과 — {how} (KIS 무응답·락 대기 가능성)" + logger.error(f"[Swing] ⏱️ {msg}") + _notify_throttled_v2(f"timeout:{step}", "SWING_SCHEDULER", msg) + + +async def _guard_cancel_v2(coro, step: str, timeout: float): + """취소형 상한(DT _guard 패턴). 초과 시 작업을 취소하고 _STEP_TIMED_OUT 반환. + + 작업이 낸 예외는 그대로 전파한다(호출부의 기존 처리 유지). + """ + try: + return await asyncio.wait_for(coro, timeout=timeout) + except asyncio.TimeoutError: + _record_step_timeout_v2(step, timeout, cancelled=True) + return _STEP_TIMED_OUT + + +def _log_detached_result_v2(step: str, task: "asyncio.Task") -> None: + if task.cancelled(): + logger.warning(f"[Swing] 상한 초과 후 백그라운드 '{step}' 취소됨") + return + exc = task.exception() + if exc is not None: + logger.error(f"[Swing] 상한 초과 후 백그라운드 '{step}' 예외: {type(exc).__name__}: {exc}") + else: + logger.warning(f"[Swing] 상한 초과 후 백그라운드 '{step}' 완료") + + +def _hard_deadline_sec_v2(timeout: float) -> float: + """비취소형 단계의 하드 데드라인(초) = 상한 × SWING_STEP_HARD_DEADLINE_MULT(최소 1배).""" + mult = _cfg_float_v2("SWING_STEP_HARD_DEADLINE_MULT", _DEFAULT_STEP_HARD_DEADLINE_MULT) + if mult < 1.0: + logger.warning(f"[Swing] SWING_STEP_HARD_DEADLINE_MULT={mult} < 1 — 기본값 {_DEFAULT_STEP_HARD_DEADLINE_MULT} 사용") + mult = _DEFAULT_STEP_HARD_DEADLINE_MULT + return float(timeout) * mult + + +def _pending_shielded_order_tasks_v2() -> list: + """살아 있는 스윙 shield 주문 태스크(_sell_and_record_v2) — 있으면 청산 판정 재시작 금지.""" + out = [] + for t in asyncio.all_tasks(): + if t.done(): + continue + coro = t.get_coro() + if getattr(coro, "__name__", "") in _SWING_SHIELDED_ORDER_CORO_NAMES: + out.append(t) + return out + + +async def _handle_hard_deadline_v2(step: str, prev: "asyncio.Task") -> bool: + """진행 중인 비취소형 단계가 하드 데드라인을 넘겼는지 판정·처리. + + 반환 True = 이전 작업을 취소·종료시켰으므로 이번 사이클에 새로 시작해도 된다. + 반환 False = 그대로 생략(_STEP_SKIPPED). + + 정책(과다매도·손절 무력화 양쪽을 막는 쪽): + - 하드 데드라인 초과 시 모든 단계에 critical 로그 + 텔레그램 + /health degraded. + - 강제 취소는 _HARD_CANCEL_STEPS('청산 판정')만. 이 단계의 주문 전송~장부 반영은 + swing/exit_service 가 asyncio.shield(_sell_and_record_v2) 로 분리해 두었으므로 바깥 + 작업을 취소해도 '전송됨·장부 미반영'이 생기지 않는다. 단 그 shield 태스크가 살아 있으면 + (=주문 전송 구간 진행 중) 취소하지 않는다 — 재시작한 판정이 같은 종목을 이중 분할매도할 수 있다. + - 'MDD 축소'·'진입' 은 trade_executor/mdd_manager 주문이 shield 되어 있지 않아 취소 시 + 중복 주문 위험이 있으므로 경보만 한다(진입 정지는 fail-closed 방향). + """ + meta = _inflight_meta.get(step) or {} + started = meta.get("started") + hard = meta.get("hard") + if started is None or hard is None: + return False + age = _now_mono_v2() - float(started) + if age <= float(hard): + return False + _hard_deadline_stats["exceeded"][step] = _hard_deadline_stats["exceeded"].get(step, 0) + 1 + + can_cancel = step in _HARD_CANCEL_STEPS + shielded = _pending_shielded_order_tasks_v2() if can_cancel else [] + if can_cancel and not shielded: + prev.cancel() + grace = _cfg_float_v2("SWING_STEP_CANCEL_GRACE_SEC", _DEFAULT_STEP_CANCEL_GRACE_SEC) + await asyncio.wait({prev}, timeout=grace) + if prev.done(): + _inflight_steps.pop(step, None) + _inflight_meta.pop(step, None) + _hard_deadline_stats["cancels"] += 1 + msg = ( + f"스윙 '{step}' 이 하드 데드라인 {hard:.0f}초를 넘겨 {age:.0f}초 정체 — 강제 취소 후 " + f"이번 사이클에 재시작(진행 중 주문 없음 확인). 정체 원인(락·외부 호출) 확인 필요" + ) + logger.critical(f"[Swing] 🚨 {msg}") + _notify_throttled_v2(f"hard_deadline:{step}", "SWING_SCHEDULER_STUCK", msg) + return True + how = f"취소 요청이 {grace:.0f}초 안에 처리되지 않음(취소 무시)" + elif can_cancel: + how = f"shield 주문 태스크 {len(shielded)}건 진행 중 — 이중 매도 방지로 취소하지 않음" + else: + how = "주문 경로(비 shield)라 취소하지 않음 — 중복 주문 방지" + msg = ( + f"스윙 '{step}' 이 하드 데드라인 {hard:.0f}초를 넘겨 {age:.0f}초 정체 — {how}. " + f"재기동 등 수동 조치 필요" + ) + logger.critical(f"[Swing] 🚨 {msg}") + _notify_throttled_v2(f"hard_deadline:{step}", "SWING_SCHEDULER_STUCK", msg) + return False + + +async def _guard_detached_v2(factory, step: str, timeout: float): + """비취소형 상한 — 주문 경로(MDD 축소·진입)용. + + - 같은 step 의 이전 작업이 진행 중이면 새로 시작하지 않고 _STEP_SKIPPED. + - 상한 안에 끝나면 결과 반환(예외는 전파). + - 초과하면 작업은 취소하지 않고 남겨 둔 채 _STEP_TIMED_OUT 반환 → 루프는 다음 + 사이클(청산 우선)로 진행한다. 주문 전송 중 취소로 "브로커 체결·장부 미반영"이 + 생겨 같은 물량을 다시 주문하는 것을 막기 위함이다. + - 스케줄러 자신이 취소되면(종료) 작업은 취소하지 않는다(shield 와 같은 의미). + """ + prev = _inflight_steps.get(step) + if prev is not None and not prev.done(): + # [하드닝 R3] 하드 데드라인 판정은 이 분기 맨 앞, try 밖(가드)에서 한다. + if not await _handle_hard_deadline_v2(step, prev): + msg = f"스윙 '{step}' 이전 작업이 아직 진행 중 — 이번 사이클 생략" + logger.error(f"[Swing] ⏱️ {msg}") + _notify_throttled_v2(f"inflight:{step}", "SWING_SCHEDULER", msg) + return _STEP_SKIPPED + task = asyncio.ensure_future(factory()) + _inflight_steps[step] = task + _inflight_meta[step] = {"started": _now_mono_v2(), "hard": _hard_deadline_sec_v2(timeout)} + done, _pending = await asyncio.wait({task}, timeout=timeout) + if task in done: + _inflight_steps.pop(step, None) + _inflight_meta.pop(step, None) + return task.result() + task.add_done_callback(lambda t, _s=step: _log_detached_result_v2(_s, t)) + _record_step_timeout_v2(step, timeout, cancelled=False) + return _STEP_TIMED_OUT + + +def _note_market_open_v2(now: float) -> None: + if _exit_health["market_open_since"] is None: + _exit_health["market_open_since"] = now + + +def _note_market_closed_v2() -> None: + _exit_health["market_open_since"] = None + + +def _record_exit_success_v2(now: float) -> None: + if _exit_health["consecutive_failures"]: + logger.warning( + f"[Swing] 청산 판정 복구 (연속 실패 {_exit_health['consecutive_failures']}회 후 정상화)" + ) + _had_fail_alert = _alert_sent_at.pop("exit_failures", None) is not None + _had_stale_alert = _alert_sent_at.pop("exit_stale", None) is not None + if _had_fail_alert or _had_stale_alert: + _notify_throttled_v2( + "exit_recovered", "SWING_EXIT", + f"스윙 청산 판정 복구 (연속 실패 {_exit_health['consecutive_failures']}회 후)", + repeat_sec=0.0, + ) + _exit_health["last_success_at"] = now + _exit_health["consecutive_failures"] = 0 + _exit_health["last_error"] = None + try: + from app.core import heartbeat as _hb + _hb.beat("swing_exit", "exit_ok") + except Exception as e: + logger.warning(f"[Swing] 청산 하트비트 기록 실패: {type(e).__name__}: {e}") + + +def _record_exit_failure_v2(err: str, timed_out: bool = False) -> None: + _exit_health["consecutive_failures"] += 1 + _exit_health["last_error"] = err + if timed_out: + _exit_health["timeouts"] += 1 + n = _exit_health["consecutive_failures"] + threshold = int(_cfg_float_v2("SWING_EXIT_FAIL_ALERT_COUNT", _DEFAULT_EXIT_FAIL_ALERT_COUNT)) + if n >= threshold: + msg = f"스윙 청산 판정 연속 실패 {n}회 — 손절·익절이 멈춰 있을 수 있음: {err}" + logger.error(f"[Swing] 🚨 {msg}") + _notify_throttled_v2("exit_failures", "SWING_EXIT", msg) + + +def _check_exit_staleness_v2(now: float) -> bool: + """장중 청산 판정 무완료 시간이 상한을 넘으면 error + 텔레그램. 넘었으면 True. + + 기준 시각 = max(마지막 정상 완료, 이번 장중 구간 첫 관측) — 밤사이 공백은 세지 않는다. + """ + open_since = _exit_health["market_open_since"] + if open_since is None: + return False + last_ok = _exit_health["last_success_at"] + ref = open_since if last_ok is None else max(last_ok, open_since) + age = now - ref + limit = _cfg_float_v2("SWING_EXIT_STALE_ALERT_SEC", _DEFAULT_EXIT_STALE_ALERT_SEC) + if age <= limit: + return False + msg = ( + f"스윙 청산 판정이 장중 {age:.0f}초 동안 완료되지 않음(상한 {limit:.0f}초) — " + f"보유 종목 손절이 방치될 수 있음. 마지막 오류: {_exit_health['last_error']}" + ) + logger.error(f"[Swing] 🚨 {msg}") + _notify_throttled_v2("exit_stale", "SWING_EXIT", msg) + return True + + +def _check_blind_block_transition_v2() -> None: + """MDD fail-safe 진입 차단(blind_block_active) 진입·해제 시점에 경보.""" + try: + h = mdd_manager.get_update_health() or {} + except Exception as e: + logger.warning(f"[Swing] MDD 헬스 조회 실패: {type(e).__name__}: {e}") + return + active = bool(h.get("blind_block_active")) + prev = _blind_prev["active"] + _blind_prev["active"] = active + if active and not prev: + msg = ( + f"MDD 갱신 연속 실패 {h.get('consecutive_failures')}회 — 신규 매수 전면 차단(fail-safe). " + f"청산은 계속. 마지막 오류: {h.get('last_error')}" + ) + logger.error(f"[Swing] 🚨 {msg}") + _notify_throttled_v2("mdd_blind_on", "MDD_FAILSAFE", msg) + elif prev and not active: + logger.warning("[Swing] MDD fail-safe 진입 차단 해제(갱신 정상화)") + _notify_throttled_v2("mdd_blind_off", "MDD_FAILSAFE", "MDD 갱신 정상화 — 신규 매수 차단 해제") + + +def get_swing_scheduler_health_v2() -> dict: + """스윙 스케줄러 감시 상태(읽기 전용) — /health 노출용. + + 청산 판정 경과·연속 실패·단계 타임아웃·진행 중 단계·텔레그램 설정 여부. + """ + now = _now_mono_v2() + last_ok = _exit_health["last_success_at"] + open_since = _exit_health["market_open_since"] + ref = None + if open_since is not None: + ref = open_since if last_ok is None else max(last_ok, open_since) + stale_limit = _cfg_float_v2("SWING_EXIT_STALE_ALERT_SEC", _DEFAULT_EXIT_STALE_ALERT_SEC) + exit_age = None if ref is None else round(now - ref, 1) + inflight_ages: dict = {} + stuck: list = [] + for k, t in _inflight_steps.items(): + if t.done(): + continue + meta = _inflight_meta.get(k) or {} + if meta.get("started") is None: + continue + a = now - float(meta["started"]) + inflight_ages[k] = round(a, 1) + if meta.get("hard") is not None and a > float(meta["hard"]): + stuck.append(k) + stuck.sort() + tg = _telegram_send_health + reasons: list = [] + if stuck: + reasons.append("stuck_steps") + if exit_age is not None and exit_age > stale_limit: + reasons.append("exit_stale") + if tg["consecutive_failures"] > 0: + reasons.append("telegram_send_failing") + if _mdd_knob_state["mismatches"]: + reasons.append("mdd_threshold_knob_mismatch") + # [하드닝 R4] 청산 판정 실행 실패·종목별 연속 예외·매도 거부(계좌 단위 포함) + try: + from app.services.swing.exit_service import get_swing_exit_health_v2 + _exh = get_swing_exit_health_v2() + except Exception as e: + logger.warning(f"[Swing] 청산 감시 상태 조회 실패: {type(e).__name__}: {e}") + _exh = {"degraded_reasons": ["exit_health_unavailable"]} + for _r in _exh.get("degraded_reasons") or (): + if _r not in reasons: + reasons.append(_r) + return { + "exit_last_success_age": None if last_ok is None else round(now - last_ok, 1), + "exit_unjudged_market_sec": exit_age, + "exit_stale": bool(exit_age is not None and exit_age > stale_limit), + "exit_consecutive_failures": _exit_health["consecutive_failures"], + "exit_timeouts": _exit_health["timeouts"], + "exit_last_error": _exit_health["last_error"], + "step_timeouts": dict(_step_timeouts), + "inflight_steps": sorted(k for k, t in _inflight_steps.items() if not t.done()), + "inflight_step_ages": inflight_ages, + "stuck_steps": stuck, + "hard_deadline_cancels": _hard_deadline_stats["cancels"], + "hard_deadline_exceeded": dict(_hard_deadline_stats["exceeded"]), + "mdd_blind_block_active": _blind_prev["active"], + "mdd_threshold_knob_mismatch": list(_mdd_knob_state["mismatches"]), + "telegram_enabled": ( + _telegram_state["enabled"] if _telegram_state["enabled"] is not None else _telegram_enabled_v2() + ), + "telegram_sent": tg["sent"], + "telegram_send_failures": tg["failures"], + "telegram_consecutive_failures": tg["consecutive_failures"], + "telegram_last_success_age": ( + None if tg["last_success_at"] is None else round(now - tg["last_success_at"], 1) + ), + "telegram_last_error": tg["last_error"], + "exit_symbol_error_counts": _exh.get("symbol_error_counts", {}), + "exit_sell_reject_counts": _exh.get("sell_reject_counts", {}), + "exit_sell_reject_last": _exh.get("sell_reject_last", {}), + "exit_last_run": _exh.get("last_run"), + "status": "degraded" if reasons else "ok", + "degraded_reasons": reasons, + } + + +# ───────────────────────────────────────────────────────────────────────────── +# [하드닝 R2 2026-10-09] MDD 갱신 입력 가드 — 입출금·부분응답을 낙폭으로 오인하지 않게 +# +# 결함: tot_evlu_amt 원값을 그대로 mdd_manager.update() 에 넣어, 출금(또는 KIS 가 일부 종목 +# 평가를 0 으로 준 응답) 한 번으로 drawdown<=-10% → is_paused + 전 보유 30% 시장가 축소 +# (되돌릴 수 없는 주문)가 실행됐다. 입금은 peak 를 부풀리거나 진행 중인 MDD 정지를 해제했다. +# +# (1) 현금흐름 보정: KIS 국내잔고 항등식 tot_evlu_amt = scts_evlu_amt + prvs_rcdl_excc_amt +# (portfolio.py 참조)에서 현금 성분 = tot − scts. 직전 관측 대비 현금 성분이 총자산의 +# MDD_CASH_FLOW_MIN_RATIO(기본 2%) 이상 움직였는데 유가평가(scts)는 그 변화의 +# MDD_CASH_FLOW_SCTS_TOLERANCE(기본 25%) 이내로만 움직였으면 외부 입출금으로 본다. +# 매수·매도는 현금↔유가가 같이 움직이고 시세 손실은 유가 쪽에서만 나므로 이 판정에 +# 걸리지 않는다(실손실을 입출금으로 숨기는 방향의 오판을 구조적으로 피한다). +# 입출금이면 peak 를 (직전총액+흐름)/직전총액 비율로 스케일해 낙폭 %를 보존한다. +# (2) 단계 전이 확인: 이번 값이 새 MDD 단계(-10%/-20%)를 유발하면 즉시 반영하지 않는다. +# (a) 보유 종목 평가 교차검증 — hldg_qty>0 인데 evlu_amt/prpr 가 0 이하이거나, output1 +# 평가합이 scts_evlu_amt 와 어긋나면 부분응답으로 보고 갱신 실패 처리(fail-safe 계수). +# MDD_STAGE_SUSPECT_MAX_HOLDS(기본 5)회 연속이면 그대로 반영한다 — 실제 낙폭을 +# 무기한 억제하지 않기 위해서다(§5.3). +# (b) MDD_STAGE_CONFIRM_CYCLES(기본 2) 사이클 연속 전이 조건이어야 반영한다. 대기 중인 +# 사이클은 신규 진입을 차단한다(실제 낙폭일 수 있으므로 보수적으로). +# 전이가 아닌 갱신(peak 갱신·단계 내 변동)은 이전과 동일하게 즉시 반영한다. +# 한계(추정): 입출금과 매매가 같은 사이클에 겹치면 (1)이 걸리지 않고 (2)만 적용된다. +# ───────────────────────────────────────────────────────────────────────────── +_DEFAULT_MDD_STAGE_CONFIRM_CYCLES = 2 +_DEFAULT_MDD_STAGE_SUSPECT_MAX_HOLDS = 5 +_DEFAULT_MDD_CASH_FLOW_MIN_RATIO = 0.02 +_DEFAULT_MDD_CASH_FLOW_SCTS_TOLERANCE = 0.25 +_DEFAULT_MDD_EVAL_SUM_TOLERANCE = 0.05 + +_mdd_gate_state: dict = { + "prev": None, # 직전 정상 관측 {"total": float, "scts": float|None} + "pending": 0, # 단계 전이 조건 연속 관측 횟수 + "suspect": 0, # 부분응답 의심으로 보류한 연속 횟수 + "last_flow": None, # 마지막으로 보정한 외부 현금흐름(원) +} + + +def _cfg_int_v2(name: str, default: int, minimum: int = 1) -> int: + raw = _raw_setting_v2(name) + if isinstance(raw, str): + raw = raw.strip() + try: + val = int(raw) if raw is not None else int(default) + except (TypeError, ValueError): + logger.warning(f"[Swing] 설정 {name}={raw!r} 비정상 — 기본값 {default} 사용") + return int(default) + if val < minimum: + logger.warning(f"[Swing] 설정 {name}={raw!r} < {minimum} — 기본값 {default} 사용") + return int(default) + return val + + +def _num_or_none_v2(raw): + if raw is None or str(raw).strip() == "": + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def _mdd_stage_thresholds_v2() -> tuple: + """(pause, force) 임계(음수 %) — mdd_manager.update() 가 **실제로 전이하는** 값. + + [하드닝 R3 2026-10-09] 예전에는 settings.MDD_PAUSE/FORCE_THRESHOLD 중 얕은 쪽을 썼다. 그래서 + 설정이 -8 이면 -8%~-10% 구간에서 전이를 '예측'해 확인 사이클(신규 진입 차단)을 채운 뒤 + update() 를 불러도 mdd_manager(-10 리터럴)는 전이하지 않고, pending 이 리셋됐다가 다시 쌓여 + 축소 없이 진입만 막혔다. 예측은 실제 전이와 같아야 하므로 mdd_manager 리터럴을 쓴다. + 설정/DB 값이 이와 다르면 _check_mdd_threshold_knobs_v2 가 '효과 없는 손잡이'로 경보한다. + """ + return _MDD_MANAGER_PAUSE_PCT, _MDD_MANAGER_FORCE_PCT + + +def _mdd_threshold_mismatches_v2(sources: dict) -> list: + """sources = {"settings": {key: raw}, "strategy_cfg": {key: raw}} → 실효 임계와 다른 항목 목록.""" + effective = {"MDD_PAUSE_THRESHOLD": _MDD_MANAGER_PAUSE_PCT, "MDD_FORCE_THRESHOLD": _MDD_MANAGER_FORCE_PCT} + out = [] + for src, vals in sources.items(): + for key, eff in effective.items(): + v = _num_or_none_v2((vals or {}).get(key)) + if v is None: + continue + if abs(-abs(v) - eff) > 1e-9: + out.append({"source": src, "key": key, "value": v, "effective": eff}) + return out + + +async def _check_mdd_threshold_knobs_v2() -> list: + """MDD 임계 '조절 손잡이'(settings·strategy_configs DB)가 실효값과 다르면 error + 텔레그램. + + mdd_manager.update() 는 -10/-20 리터럴로만 전이하므로 다른 값은 아무 효과가 없다 + (운영자가 UI 에서 바꿔도 반영되지 않는다). 읽기 전용 — strategy_cfg 캐시 조회(SELECT)만. + """ + sources: dict = {"settings": { + "MDD_PAUSE_THRESHOLD": getattr(settings, "MDD_PAUSE_THRESHOLD", None), + "MDD_FORCE_THRESHOLD": getattr(settings, "MDD_FORCE_THRESHOLD", None), + }} + try: + from app.services.strategy_config_service import strategy_cfg + db_vals = {} + for key in ("MDD_PAUSE_THRESHOLD", "MDD_FORCE_THRESHOLD"): + db_vals[key] = await asyncio.wait_for(strategy_cfg.aget(key, None), timeout=10.0) + sources["strategy_cfg"] = db_vals + except Exception as e: + logger.warning(f"[Swing] MDD 임계 DB 파라미터 조회 실패(대조 생략): {type(e).__name__}: {e}") + mism = _mdd_threshold_mismatches_v2(sources) + _mdd_knob_state["mismatches"] = mism + if mism: + detail = ", ".join(f"{m['source']}.{m['key']}={m['value']}" for m in mism) + msg = ( + f"MDD 임계 설정이 실효값(진입중단 {_MDD_MANAGER_PAUSE_PCT}% / 강제축소 {_MDD_MANAGER_FORCE_PCT}%)과 " + f"다름 — 효과 없음(mdd_manager 는 고정 임계로 전이): {detail}" + ) + logger.error(f"[Swing] 🚨 {msg}") + _notify_throttled_v2("mdd_threshold_knob", "MDD_CONFIG", msg) + return mism + + +def _detect_cash_flow_v2(prev, total: float, scts) -> float | None: + """직전 관측 대비 외부 입출금 추정액(원, 출금 음수). 판정 불가·해당 없음이면 None.""" + if not prev or scts is None or prev.get("scts") is None: + return None + prev_total = float(prev.get("total") or 0.0) + if prev_total <= 0: + return None + d_cash = (total - scts) - (prev_total - float(prev["scts"])) + d_scts = scts - float(prev["scts"]) + min_ratio = _cfg_float_v2("MDD_CASH_FLOW_MIN_RATIO", _DEFAULT_MDD_CASH_FLOW_MIN_RATIO) + tol = _cfg_float_v2("MDD_CASH_FLOW_SCTS_TOLERANCE", _DEFAULT_MDD_CASH_FLOW_SCTS_TOLERANCE) + if abs(d_cash) < prev_total * min_ratio: + return None + if abs(d_scts) > abs(d_cash) * tol: + return None # 매매(현금↔유가 동반 이동) 또는 시세 변동 — 입출금으로 보지 않음 + return d_cash + + +def _holdings_eval_suspect_v2(balance_resp, scts) -> str | None: + """보유 평가 교차검증 — 부분응답 의심 사유 문자열, 정상/판정불가면 None. + + 필드가 아예 없는 경우는 의심으로 보지 않는다(필드명 불일치로 실제 MDD 를 막지 않게). + """ + holdings = (balance_resp or {}).get("output1") + if not isinstance(holdings, list): + return None + evals = [] + for h in holdings: + if not isinstance(h, dict): + continue + qty = _num_or_none_v2(h.get("hldg_qty")) + if not qty or qty <= 0: + continue + ev = _num_or_none_v2(h.get("evlu_amt")) + px = _num_or_none_v2(h.get("prpr")) + code = h.get("pdno") or "?" + if (ev is not None and ev <= 0) or (px is not None and px <= 0): + return f"보유 {code} 평가 0 (evlu_amt={h.get('evlu_amt')!r}, prpr={h.get('prpr')!r})" + evals.append(ev) + if scts is None or not evals or any(e is None for e in evals): + return None + tol = _cfg_float_v2("MDD_EVAL_SUM_TOLERANCE", _DEFAULT_MDD_EVAL_SUM_TOLERANCE) + s = sum(evals) + if s > scts * (1 + tol) + 1: + return f"보유 평가합 {s:,.0f} > 유가평가 {scts:,.0f}" + from app.services.mdd_manager import _balance_page_full_v2 + if not _balance_page_full_v2(holdings) and s < scts * (1 - tol) - 1: + return f"보유 평가합 {s:,.0f} < 유가평가 {scts:,.0f} (페이지 미만)" + return None + + +def _predict_mdd_transition_v2(total: float) -> str | None: + """이 값으로 update() 하면 새 단계로 전이하는지 (I/O 없음, 판정만).""" + peak = float(getattr(mdd_manager, "peak_balance", 0.0) or 0.0) + if getattr(mdd_manager, "_rebase_peak_pending", False) or peak <= 0 or total >= peak: + return None + dd = (total - peak) / peak * 100.0 + pause, force = _mdd_stage_thresholds_v2() + applied = getattr(mdd_manager, "reduction_applied", None) + if dd <= force and applied != "20pct": + return "20pct" + if dd <= pause and applied is None: + return "10pct" + return None + + +async def _rescale_peak_for_cash_flow_v2(prev_total: float, flow: float) -> None: + """외부 입출금만큼 peak 를 비율 스케일(낙폭 % 보존) + Redis 영속화(락 밖).""" + after = prev_total + flow + if prev_total <= 0 or after <= 0: + return + ratio = after / prev_total + async with mdd_manager._lock: + old = float(mdd_manager.peak_balance or 0.0) + if old <= 0: + return + mdd_manager.peak_balance = old * ratio + _mdd_gate_state["last_flow"] = flow + logger.warning( + f"[Swing] MDD 입출금 감지 추정 {flow:+,.0f}원 — peak {old:,.0f} → " + f"{old * ratio:,.0f} 보정(낙폭 % 보존). 수동 확인 권장" + ) + _notify_throttled_v2( + "mdd_cash_flow", "MDD_CASH_FLOW", + f"계좌 입출금 추정 {flow:+,.0f}원 — MDD peak 를 비율 보정했습니다(낙폭 오인 방지). 수동 확인 권장", + ) + await mdd_manager.save_to_redis() + + +def _mdd_has_known_balance_v2() -> bool: + """[하드닝 R1-라운드1] MDD 관리자가 이미 양(+)의 자산을 관측했는가 (I/O 없음). + + peak_balance 또는 마지막 갱신 잔고가 >0 이면 '운용 중 계좌'로 본다. 이 상태의 + tot_evlu_amt<=0 응답은 빈 계좌가 아니라 부분/오류 응답으로 취급해야 한다. + 값 해석이 불가하면 True(=빈 계좌로 인정하지 않음 — fail-safe 쪽). + """ + try: + peak = float(getattr(mdd_manager, "peak_balance", 0.0) or 0.0) + last = float(getattr(mdd_manager, "_last_balance", 0.0) or 0.0) + except (TypeError, ValueError) as e: + logger.warning(f"[Swing] MDD 기존 잔고 해석 실패 — 빈 계좌 판정 불허: {type(e).__name__}: {e}") + return True + return peak > 0 or last > 0 + + +async def _mdd_update_guarded_v2(total: float, balance_resp) -> dict: + """mdd_manager.update(total) 앞단 가드. 반환 {"status": "applied"|"pending", "result": dict|None}. + + 부분응답 의심은 ValueError 로 올려 호출부의 갱신 실패 경로(fail-safe 계수)를 탄다. + """ + st = _mdd_gate_state + out2 = ((balance_resp or {}).get("output2") or [{}])[0] or {} + scts = _num_or_none_v2(out2.get("scts_evlu_amt")) + prev = st["prev"] + + flow = _detect_cash_flow_v2(prev, total, scts) + if flow is not None: + await _rescale_peak_for_cash_flow_v2(float(prev["total"]), flow) + + stage = _predict_mdd_transition_v2(total) + if stage is None: + st["pending"] = 0 + st["suspect"] = 0 + st["prev"] = {"total": total, "scts": scts} + return {"status": "applied", "result": await mdd_manager.update(total)} + + suspect = _holdings_eval_suspect_v2(balance_resp, scts) + if suspect: + st["suspect"] += 1 + max_holds = _cfg_int_v2("MDD_STAGE_SUSPECT_MAX_HOLDS", _DEFAULT_MDD_STAGE_SUSPECT_MAX_HOLDS) + if st["suspect"] < max_holds: + # 직전 정상 관측(prev)은 유지 — 부분응답을 흐름 판정 기준으로 쓰지 않는다 + raise ValueError( + f"MDD {stage} 전이 보류 — 잔고 부분응답 의심 {st['suspect']}/{max_holds}: {suspect}" + ) + logger.error( + f"[Swing] 🚨 MDD {stage} 전이 — 부분응답 의심이 {st['suspect']}회 연속이라 그대로 반영: {suspect}" + ) + st["suspect"] = 0 + st["prev"] = {"total": total, "scts": scts} + st["pending"] += 1 + need = _cfg_int_v2("MDD_STAGE_CONFIRM_CYCLES", _DEFAULT_MDD_STAGE_CONFIRM_CYCLES) + if st["pending"] < need: + logger.warning( + f"[Swing] MDD {stage} 전이 조건 관측 {st['pending']}/{need} — 확인 대기" + f"(총평가 {total:,.0f}, peak {mdd_manager.peak_balance:,.0f}). 이번 사이클 신규 진입 차단" + ) + return {"status": "pending", "result": None} + st["pending"] = 0 + return {"status": "applied", "result": await mdd_manager.update(total)} + + +async def _run_exit_phase_v2(): + """청산 판정 1회. 청산 판정 성공/실패를 직접 기록한다. + + 비취소형 상한(_guard_detached_v2) 아래에서 돌기 때문에 세션을 스스로 연다 — 상한 초과로 + 루프가 먼저 진행해도 세션이 작업보다 먼저 닫히지 않게 하기 위함이다. + [하드닝 R8 2026-10-10] 체결가 후속 보정(sweep)은 여기서 분리해 _run_fill_sweep_phase_v2 + (취소형 별도 단계)로 옮겼다. 이전엔 sweep(최대 SWING_FILL_SWEEP_TIMEOUT_SEC)이 청산 판정과 + 같은 비취소형 상한(SWING_EXIT_STEP_TIMEOUT_SEC)을 공유해, 청산이 끝났는데도 상한 초과로 + '청산 실패' 집계·다음 사이클 _STEP_SKIPPED·가짜 연속 실패 경보가 날 수 있었다. + 반환: check_and_execute_exit_v2 결과(list) 또는 예외 시 None. + """ + exits = None + # DB-READ-EXCEPTION: 청산 판정용 SELECT 전용 세션(commit 없음, 쓰기는 db_task_queue 경유) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 SELECT, 시간손절 TradeHistory SELECT 포함 + async with AsyncSessionLocal() as exit_session: + try: + # [하드닝 R4 2026-10-09] check_and_execute_exit_v2 는 '전체 오류'·'보유 종목 전부 예외'를 + # 삼키고 정상 반환한다. 실행 일련번호로 이번 실행분 결과를 확인해 실패로 집계한다 + # (이전엔 무조건 성공 기록 → 연속 실패·무완료 경보 우회). + from app.services.swing import exit_service as _es_run + _seq0 = _es_run.get_last_exit_run_status_v2()["seq"] + exits = await check_and_execute_exit_v2(exit_session) + _run_st = _es_run.get_last_exit_run_status_v2() + if _run_st["seq"] != _seq0 and not _run_st["ok"]: + logger.error(f"[Swing] 청산 판정 실패(예외 삼킴 경로): {_run_st['error']}") + _record_exit_failure_v2(str(_run_st["error"])) + else: + _record_exit_success_v2(_now_mono_v2()) + if exits: + logger.info(f"[Swing] 청산 실행: {len(exits)}건") + except Exception as e: + logger.error(f"[Swing] 청산 체크 오류: {e}", exc_info=True) + _record_exit_failure_v2(f"{type(e).__name__}: {e}") + return exits + + +async def _run_fill_sweep_phase_v2(): + """체결가 미확인 TradeHistory 후속 보정(swing/fill_sweep) — 청산 판정과 분리된 취소형 단계. + + 주문이 없는 조회·보정 경로라 취소형 상한(SWING_FILL_SWEEP_TIMEOUT_SEC)을 쓴다. + 갱신은 db_task_queue 경유(swing/fill_sweep). 청산 판정의 성공/실패 집계에 관여하지 않는다. + 청산 판정 작업이 아직 살아 있으면(상한 초과 후 백그라운드 계속) 이번 사이클은 생략한다 — + 진행 중인 매도와 같은 행을 동시에 보정하지 않기 위함(이전 동작: sweep 은 청산 판정 뒤에만 실행). + 반환: 보정 건수, _STEP_TIMED_OUT, 또는 생략·예외 시 None. + """ + _prev_exit = _inflight_steps.get("청산 판정") + if _prev_exit is not None and not _prev_exit.done(): + logger.warning("[Swing] 청산 판정 진행 중 — 체결가 후속 보정 이번 사이클 생략") + return None + try: + from app.services.swing.fill_sweep import sweep_unconfirmed_fills_v2 + # DB-READ-EXCEPTION: 체결가 후속 보정 대상 SELECT 전용 세션(commit 없음, 갱신은 db_task_queue 경유) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 SELECT + async with AsyncSessionLocal() as sweep_session: + _swept = await _guard_cancel_v2( + sweep_unconfirmed_fills_v2(sweep_session), "체결가 후속 보정", + _cfg_float_v2("SWING_FILL_SWEEP_TIMEOUT_SEC", _DEFAULT_FILL_SWEEP_TIMEOUT_SEC), + ) + if _swept and _swept is not _STEP_TIMED_OUT: + logger.info(f"[Swing] 체결가 후속 보정: {_swept}건") + return _swept + except Exception as e: + logger.warning(f"[Swing] 체결가 후속 보정 실패: {type(e).__name__}: {e}") + return None + + +async def _run_entry_phase_v2( + get_json, get_rule_based_recommendations_from_db, request_mcp_risk_assessment, +) -> None: + """평균회귀 진입 → 듀얼 스캔(순서 유지, M-1). 하나의 비취소형 단계로 묶어 상한을 건다. + + 평균회귀 단계 예외는 듀얼 스캔을 막지 않는다(H5). + """ + try: + await _run_recommendation_entry_v2( + get_json, + get_rule_based_recommendations_from_db, + request_mcp_risk_assessment, + ) + except Exception as e: + logger.error(f"[Swing] 평균회귀 진입 단계 오류 — 듀얼 스캔은 계속: {e}", exc_info=True) + + # 듀얼 전략(모멘텀 돌파·눌림목) — MCP 평가 대상 아님(2026-10-02 사용자 결정) + await _run_dual_strategy_scan_v2() + + def _normalize_recommendations(rec: dict) -> dict: """recommendations_by_rule 키 정규화. @@ -267,6 +1201,11 @@ async def swing_scheduler() -> None: from app.core.redis import get_json logger.info("📈 [Swing] Scheduler started (every 60s)") + _check_telegram_configured_v2() + try: + await _check_mdd_threshold_knobs_v2() + except Exception as e: + logger.warning(f"[Swing] MDD 임계 설정 대조 실패: {type(e).__name__}: {e}") _mdd_update_failures = 0 _mdd_last_error: str | None = None # [M3] '정상 응답이지만 총평가금액 0'(빈/미입금 계좌) 누적 횟수 — 장애 카운터와 분리 @@ -275,24 +1214,58 @@ async def swing_scheduler() -> None: while True: try: logger.info("[Swing] ── 사이클 시작 ──") + # [하드닝 R1] 사이클 하트비트 — 반복 시작마다 찍는다(어느 단계에서 멈추든 + # main.py 감시 루프가 장중 정체로 잡도록). 기록 실패가 루프를 막지 않게 흡수. + try: + from app.core import heartbeat as _hb + _hb.beat("swing", "cycle_start") + except Exception as _hb_e: + logger.warning(f"[Swing] 사이클 하트비트 기록 실패: {type(_hb_e).__name__}: {_hb_e}") if not is_market_open(): + _note_market_closed_v2() logger.info("[Swing] 장 미오픈 — 개장까지 대기") # 개장을 넘겨 자지 않도록 상한 (2026-09-23) from app.core.market_schedule import seconds_until_market_open _until_open = seconds_until_market_open() - await asyncio.sleep(1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open))) + _closed_sleep = 1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open)) + # [하드닝 R4 2026-10-09] 종가 동시호가(15:20~15:30) 주문은 15:30 에 체결된다 — + # 장 마감 직후 창에서 체결가 후속 보정(swing/fill_sweep). 창 안이면 대기를 줄인다. + try: + from app.services.swing.fill_sweep import run_post_close_fill_sweep_v2 + _pc_sleep = await run_post_close_fill_sweep_v2() + if _pc_sleep is not None: + _closed_sleep = min(_closed_sleep, float(_pc_sleep)) + except Exception as _pc_e: + logger.warning(f"[Swing] 장 마감 후 체결가 보정 실패: {type(_pc_e).__name__}: {_pc_e}") + await asyncio.sleep(_closed_sleep) continue - # ── Phase 1: 청산 체크 (별도 세션) ── - # DB-READ-EXCEPTION: 청산 판정용 SELECT 전용 세션(commit 없음, 쓰기는 db_task_queue 경유) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 SELECT, 시간손절 TradeHistory SELECT 포함 - async with AsyncSessionLocal() as exit_session: - try: - exits = await check_and_execute_exit_v2(exit_session) - if exits: - logger.info(f"[Swing] 청산 실행: {len(exits)}건") - except Exception as e: - logger.error(f"[Swing] 청산 체크 오류: {e}", exc_info=True) + # ── Phase 1: 청산 체크 (별도 세션, _run_exit_phase_v2) ── + # [하드닝 R1] 비취소형 상한(SWING_EXIT_STEP_TIMEOUT_SEC). 초과 시 판정 작업은 + # 백그라운드로 계속되고 루프는 MDD 단계로 진행한다. 끝날 때까지 새 청산 판정을 + # 시작하지 않는다 — 취소 후 재시작하면 shield 로 계속 진행 중인 매도와 새 판정의 + # 매도가 같은 종목에 겹쳐 분할 매도가 이중 실행될 수 있다(과다매도). + _note_market_open_v2(_now_mono_v2()) + _exit_timeout = _cfg_float_v2("SWING_EXIT_STEP_TIMEOUT_SEC", _DEFAULT_EXIT_STEP_TIMEOUT_SEC) + try: + _exit_res = await _guard_detached_v2(_run_exit_phase_v2, "청산 판정", _exit_timeout) + if _exit_res is _STEP_TIMED_OUT: + _record_exit_failure_v2(f"청산 판정 {_exit_timeout:.0f}초 초과", timed_out=True) + elif _exit_res is _STEP_SKIPPED: + _record_exit_failure_v2("이전 청산 판정이 아직 진행 중(상한 초과 상태)") + except Exception as e: + logger.error(f"[Swing] 청산 단계 오류: {e}", exc_info=True) + _record_exit_failure_v2(f"{type(e).__name__}: {e}") + try: + from app.core import heartbeat as _hb + _hb.beat("swing", "exit_phase_done") + except Exception as _hb_e: + logger.warning(f"[Swing] 사이클 하트비트 기록 실패: {type(_hb_e).__name__}: {_hb_e}") + # 청산 판정 무완료 감시(장중, 개장 시각 기준) — try 밖(가드) + _check_exit_staleness_v2(_now_mono_v2()) + # [하드닝 R8] 체결가 후속 보정 — 청산 판정 상한과 분리된 취소형 단계(내부에서 예외 흡수) + await _run_fill_sweep_phase_v2() # ── Phase 2: MDD 업데이트 + 체크 + 강제 축소 ── # [FIX] mdd_manager.update()를 매 사이클 호출하여 MDD 감지 활성화 @@ -304,9 +1277,14 @@ async def swing_scheduler() -> None: # MDD 갱신만 스킵한다 — 그러지 않으면 신규 모의계좌에서 매 사이클 실패가 # 누적돼 🚨 error 로그가 계속 찍혀 실제 KIS 장애와 구분되지 않는다. _empty_account = False + _mdd_stage_pending = False try: from app.services.korea_investment_api import korea_investment_api - balance_resp = await korea_investment_api.get_balance() + # [하드닝 R1] 읽기 전용 → 취소형 상한. 초과는 TimeoutError 로 아래 실패 경로를 탄다. + balance_resp = await asyncio.wait_for( + korea_investment_api.get_balance(), + timeout=_cfg_float_v2("SWING_BALANCE_TIMEOUT_SEC", _DEFAULT_BALANCE_TIMEOUT_SEC), + ) if not balance_resp: raise ValueError("잔고 조회 응답 없음 (resp=없음)") _out2 = balance_resp.get("output2") @@ -319,8 +1297,19 @@ async def swing_scheduler() -> None: _total = float(_raw_total) except (TypeError, ValueError): raise ValueError(f"잔고 응답 파싱 실패 (tot_evlu_amt={_raw_total!r})") + if _total <= 0 and _mdd_has_known_balance_v2(): + # [하드닝 R1-라운드1] 이미 자산을 관측한(peak/last>0) 운용 계좌에서 0 은 + # '빈 계좌'가 아니라 부분/오류 응답으로 본다 → 실패 경로(fail-safe 계수). + # 빈 계좌로 분류하면 blind 차단·staleness 가 모두 해제돼 실제 -10%/-20% + # 구간에서도 신규 진입이 열린다. (실제 전액 출금이면 자산 0 이라 매수 + # 자체가 불가하므로 차단 비용이 없다.) + raise ValueError( + f"잔고 응답 이상 (tot_evlu_amt={_total:,.0f} 이나 기존 peak=" + f"{float(getattr(mdd_manager, 'peak_balance', 0.0) or 0.0):,.0f} — " + f"빈 계좌로 보지 않고 갱신 실패 처리)" + ) if _total <= 0: - # 정상 응답이지만 자산 0 — 빈 계좌/미입금. 장애 아님. + # 정상 응답이지만 자산 0 — 빈 계좌/미입금(자산 관측 이력 없음). 장애 아님. _empty_account = True _mdd_empty_account_seen += 1 if _mdd_empty_account_seen == 1 or _mdd_empty_account_seen % 60 == 0: @@ -335,7 +1324,10 @@ async def swing_scheduler() -> None: # 빈 계좌는 진입 차단 사유가 아니다(항목7). await mdd_manager.record_update_success(empty_account=True) if _total > 0: - mdd_result = await mdd_manager.update(_total) + # [하드닝 R2] 입출금 보정 + 단계 전이 확인(부분응답 의심은 ValueError → 실패 경로) + _guarded = await _mdd_update_guarded_v2(_total, balance_resp) + _mdd_stage_pending = _guarded["status"] == "pending" + mdd_result = _guarded["result"] or {"action": "PENDING_CONFIRM"} # [항목7] 갱신 성공 → fail-safe 차단 해제 await mdd_manager.record_update_success(empty_account=False) if _mdd_update_failures: @@ -371,7 +1363,12 @@ async def swing_scheduler() -> None: _mdd_health["empty_account"] = _empty_account can_open_new = await mdd_manager.can_open_new_position() + # [하드닝 R2] MDD 단계 전이 확인 대기 중엔 신규 진입 차단 (try 밖 — 가드) + if _mdd_stage_pending: + can_open_new = False logger.info(f"[Swing] MDD 체크: can_open={can_open_new}") + # [하드닝 R1] fail-safe 진입 차단 진입/해제 경보 (판정 자체는 위 메서드가 소유) + _check_blind_block_transition_v2() if not can_open_new: if mdd_manager.get_update_health().get("blind_block_active"): logger.error( @@ -389,7 +1386,14 @@ async def swing_scheduler() -> None: "reduce_pct": reduce_pct, "prioritize_leverage": mdd_manager.reduction_applied == "20pct", } - results = await mdd_manager.execute_reduction(mdd_action) + # [하드닝 R1] 주문 경로라 비취소형 상한 — 초과 시 백그라운드로 남기고 + # 끝날 때까지 새 축소를 시작하지 않는다(이중 축소 방지). + results = await _guard_detached_v2( + lambda: mdd_manager.execute_reduction(mdd_action), "MDD 축소", + _cfg_float_v2("SWING_MDD_REDUCTION_TIMEOUT_SEC", _DEFAULT_MDD_REDUCTION_TIMEOUT_SEC), + ) + if results is _STEP_TIMED_OUT or results is _STEP_SKIPPED: + results = None if results: logger.warning(f"[Swing] MDD 강제 축소 완료: {len(results)}종목") except Exception as e: @@ -410,17 +1414,17 @@ async def swing_scheduler() -> None: # 0종목·MCP 거부)·예외는 듀얼을 건너뛰지 않는다(H5 의도 유지) — 헬퍼가 # continue 대신 return 하고, 예외는 여기서 흡수한다. 한도가 이미 찼으면 # scan_dual_strategy_signals 가 일봉 조회 전에 생략한다. - try: - await _run_recommendation_entry_v2( + # [하드닝 R1] 진입(평균회귀→듀얼)은 주문 경로라 비취소형 상한. 초과 시 루프는 + # 다음 사이클(청산 우선)로 진행하고, 끝날 때까지 새 진입을 시작하지 않는다. + await _guard_detached_v2( + lambda: _run_entry_phase_v2( get_json, get_rule_based_recommendations_from_db, request_mcp_risk_assessment, - ) - except Exception as e: - logger.error(f"[Swing] 평균회귀 진입 단계 오류 — 듀얼 스캔은 계속: {e}", exc_info=True) - - # 듀얼 전략(모멘텀 돌파·눌림목) — MCP 평가 대상 아님(2026-10-02 사용자 결정) - await _run_dual_strategy_scan_v2() + ), + "진입", + _cfg_float_v2("SWING_ENTRY_PHASE_TIMEOUT_SEC", _DEFAULT_ENTRY_PHASE_TIMEOUT_SEC), + ) logger.info("[Swing] ── 사이클 완료 ──") diff --git a/backend/app/services/trade_analyzer.py b/backend/app/services/trade_analyzer.py index 68bc3c4..99c0c3e 100644 --- a/backend/app/services/trade_analyzer.py +++ b/backend/app/services/trade_analyzer.py @@ -87,9 +87,60 @@ def _parse_exit_type(exit_reason: Optional[str]) -> str: return "manual" if "rsi" in reason: return "rsi_exit" + # [2026-10-09 R1] 기존 분류는 그대로 두고, 그동안 unknown 이던 사유만 새 키워드로 분리한다. + if "목표가" in reason or "target" in reason: + return "take_profit" + if "미청산" in reason or "stale" in reason: + return "stale_close" + if "bb상단" in reason or "bb_upper" in reason or "과매수" in reason: + return "bb_upper_exit" return "unknown" +# ───────────────────────────────────────────────────────────────────────────── +# 비용 추정 (2026-10-09 R1) +# 운영 trade_history 의 commission IS NULL 인 SELL 은 net_profit_loss 가 비용 미차감(총손익)이다. +# 정의는 trade_helpers.calculate_trade_costs 와 동일(수수료×2 + 매도세). 무거운 import 를 피하려고 +# settings 요율을 직접 참조한다(테스트로 동일성 고정). +# ───────────────────────────────────────────────────────────────────────────── + +def _is_gross_basis_v2(t) -> bool: + """commission 이 NULL 이면 비용이 net_profit_loss 에 반영되지 않은 행으로 본다.""" + return getattr(t, "commission", None) is None + + +def _estimated_cost_v2(t) -> float: + """commission 이 없는 SELL 의 왕복 비용 추정(원). commission 이 있으면 0(이미 차감됨).""" + if not _is_gross_basis_v2(t): + return 0.0 + from app.core.config import settings + amount = _to_float(getattr(t, "total_amount", None)) + if amount <= 0: + amount = _to_float(getattr(t, "executed_price", None)) * _trade_qty_v2(t) + if amount <= 0: + return 0.0 + market = str(getattr(t, "market", None) or settings.DAY_TRADE_DEFAULT_TAX_MARKET) + tax = (settings.KR_SELL_TAX_RATE_KOSDAQ if market.upper() == "KOSDAQ" + else settings.KR_SELL_TAX_RATE_KOSPI) + return round(amount * (2 * settings.KR_COMMISSION_RATE + tax), 0) + + +def _adjusted_net_v2(t) -> float: + return _to_float(getattr(t, "net_profit_loss", None)) - _estimated_cost_v2(t) + + +def _cost_adjustment_v2(trades: List[Any]) -> Dict[str, Any]: + gross_rows = [t for t in trades if _is_gross_basis_v2(t)] + est = sum(_estimated_cost_v2(t) for t in gross_rows) + net = sum(_to_float(getattr(t, "net_profit_loss", None)) for t in trades) + return { + "gross_basis_trades": len(gross_rows), + "estimated_cost": round(est, 2), + "estimated_cost_adjusted_net": round(net - est, 2), + "cost_basis_note": "commission NULL 행은 비용 미차감(총손익)으로 보고 추정 비용을 차감한 값을 병기", + } + + # ───────────────────────────────────────────────────────────────────────────── # 리스크 조정 수익률 지표 헬퍼 # ───────────────────────────────────────────────────────────────────────────── @@ -490,6 +541,7 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any "max_consecutive_loss": max_consecutive_loss, "risk_reward_ratio": round(abs(avg_win / avg_loss), 4) if avg_loss != 0 else 0.0, } + summary.update(_cost_adjustment_v2(trades)) # ── 전략별 성과 ────────────────────────────────────────────────────── by_strategy: Dict[str, list] = {} @@ -593,6 +645,580 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any } +# ───────────────────────────────────────────────────────────────────────────── +# v2 성과 분석 (2026-10-09 하드닝 R1) +# - 라운드트립(포지션) 단위 귀인: entry_trade_id 가 NULL 이어도 종목별 FIFO 로 BUY 와 연결 +# - 시스템 성과(manual/external 제외)와 전체를 분리 +# - Sharpe/Sortino/Calmar: KRX 거래일 전체에 0 수익을 채우고 실제 자본(세션 starting_balance) 분모 +# 기존 analyze_performance 및 헬퍼 시그니처는 그대로 둔다(호출처 호환). +# ───────────────────────────────────────────────────────────────────────────── + +# 시스템 매매가 아닌(수동 판단·외부 유입) strategy_type. 전략 성과에서 분리한다. +NON_SYSTEM_STRATEGY_TYPES = frozenset({"manual", "external"}) +# 라운드트립 매칭용 BUY 조회 룩백(일) — 윈도우 이전에 진입한 포지션의 청산을 연결하기 위함 +_ROUNDTRIP_BUY_LOOKBACK_DAYS = 90 +# 표본이 이보다 적으면 low_sample 로 표시(신뢰구간 병기) +_LOW_SAMPLE_ACTIVE_DAYS = 30 +_FALLBACK_CAPITAL_KRW = 10_000_000.0 + + +def _is_system_trade_v2(t) -> bool: + st = (getattr(t, "strategy_type", None) or "").strip().lower() + return st not in NON_SYSTEM_STRATEGY_TYPES + + +def _trade_qty_v2(t) -> int: + q = getattr(t, "executed_quantity", None) or getattr(t, "order_quantity", None) or 0 + try: + return int(q) + except (TypeError, ValueError): + return 0 + + +def _trade_sort_key_v2(t): + ts = getattr(t, "created_at", None) or datetime.min + return (ts, getattr(t, "id", 0) or 0) + + +def _match_roundtrips_v2(buys: List[Any], sells: List[Any]) -> List[Dict[str, Any]]: + """BUY/SELL TradeHistory 를 종목별로 시간순 매칭해 SELL 마다 진입 BUY 를 연결한다. + + - SELL.entry_trade_id 가 가리키는 BUY(남은 수량 있음)가 있으면 그것을 우선 사용 + - 아니면 같은 종목의 가장 오래된 BUY lot(동일 strategy_type 우선) FIFO + - 수량이 여러 lot 에 걸치면 첫 소진 lot 에 귀속 + - 매칭 불가(윈도우 밖 진입 등)는 entry_trade_id=None 의 단독 포지션 + 반환: SELL 순서와 무관한 dict 리스트(각 SELL 1개). + """ + out: List[Dict[str, Any]] = [] + + # 종목별 이벤트 시간순 처리 + events: Dict[str, List[Any]] = defaultdict(list) + for b in buys: + events[b.stock_code].append(("B", b)) + for sl in sells: + events[sl.stock_code].append(("S", sl)) + + for code, evs in events.items(): + evs.sort(key=lambda e: (_trade_sort_key_v2(e[1]), 0 if e[0] == "B" else 1)) + code_lots: List[List[Any]] = [] + for kind, t in evs: + if kind == "B": + code_lots.append([t, _trade_qty_v2(t)]) + continue + need = _trade_qty_v2(t) + chosen: Optional[List[Any]] = None + linked_by: Optional[str] = None + link_id = getattr(t, "entry_trade_id", None) + if link_id: + for lot in code_lots: + if lot[0].id == link_id and lot[1] > 0: + chosen = lot + linked_by = "entry_trade_id" + break + if chosen is None: + st = getattr(t, "strategy_type", None) + cands = [l for l in code_lots if l[1] > 0] + same = [l for l in cands if getattr(l[0], "strategy_type", None) == st] + pool = same or cands + chosen = pool[0] if pool else None + if chosen is not None: + linked_by = "fifo" + first_lot = chosen + remaining_need = need + if chosen is not None: + take = min(chosen[1], remaining_need) + chosen[1] -= take + remaining_need -= take + # 초과분은 다음 lot FIFO 로 소진(귀속은 첫 lot) + for lot in code_lots: + if remaining_need <= 0: + break + if lot[1] > 0: + tk = min(lot[1], remaining_need) + lot[1] -= tk + remaining_need -= tk + out.append({"sell": t, "buy": first_lot[0] if first_lot else None, "lot": first_lot, + "linked_by": linked_by}) + + # 포지션 종료 여부: lot 잔량 0 (lot 객체 공유) + for r in out: + r["lot_remaining"] = r["lot"][1] if r["lot"] is not None else 0 + return out + + +def _sell_cost_basis_v2(sell, buy) -> Optional[float]: + """해당 SELL 이 청산한 수량의 원가. BUY 체결가 우선, 없으면 매도대금-순손익 역산.""" + qty = _trade_qty_v2(sell) + if buy is not None: + bp = _to_float(getattr(buy, "executed_price", None)) or _to_float(getattr(buy, "order_price", None)) + if bp > 0 and qty > 0: + return bp * qty + sp = _to_float(getattr(sell, "executed_price", None)) + if sp > 0 and qty > 0: + cost = sp * qty - _to_float(getattr(sell, "net_profit_loss", None)) + if cost > 0: + return cost + return None + + +def _holding_bucket_v2(days: Optional[float]) -> str: + if days is None: + return "unknown" + if days < 1: + return "intraday" + if days <= 5: + return "1-5d" + if days <= 20: + return "6-20d" + return "20d+" + + +def _build_positions_v2(matches: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """SELL 매칭 결과를 진입 BUY 단위 포지션으로 합친다(분할매도 = 1 포지션).""" + groups: Dict[Any, List[Dict[str, Any]]] = {} + for m in matches: + key = m["buy"].id if m["buy"] is not None else ("orphan", m["sell"].id) + groups.setdefault(key, []).append(m) + + positions: List[Dict[str, Any]] = [] + for key, ms in groups.items(): + buy = ms[0]["buy"] + sells = sorted((m["sell"] for m in ms), key=_trade_sort_key_v2) + net = sum(_to_float(s.net_profit_loss) for s in sells) + costs = [_sell_cost_basis_v2(m["sell"], buy) for m in ms] + cost = sum(c for c in costs if c) if any(costs) else None + entry_at = getattr(buy, "created_at", None) if buy is not None else None + last_exit = sells[-1].created_at + holding = None + if entry_at is not None and last_exit is not None: + holding = max((last_exit - entry_at).total_seconds() / 86400.0, 0.0) + ref = buy if buy is not None else sells[0] + closed = buy is None or all(m["lot_remaining"] <= 0 for m in ms) + positions.append({ + "entry_trade_id": buy.id if buy is not None else None, + "stock_code": sells[0].stock_code, + "strategy_type": (getattr(ref, "strategy_type", None) or "").strip().lower() or "unknown", + "strategy": _parse_strategy(getattr(ref, "entry_reason", None), getattr(ref, "strategy_type", None)), + "entry_reason": (getattr(buy, "entry_reason", None) or "")[:80] if buy is not None else "", + "exit_types": sorted({ + ("manual_exit" if not _is_system_trade_v2(s) else _parse_exit_type(s.exit_reason)) + for s in sells}), + "manual_exits": sum(1 for s in sells if not _is_system_trade_v2(s)), + "n_exits": len(sells), + "net_pnl": net, + "cost": cost, + "return_pct": (net / cost * 100.0) if cost else None, + "holding_days": holding, + "closed": closed, + "last_exit_at": last_exit, + }) + positions.sort(key=lambda p: (p["last_exit_at"] is None, p["last_exit_at"])) + return positions + + +def _position_stats_v2(positions: List[Dict[str, Any]]) -> Dict[str, Any]: + """포지션 단위 승률·PF·금액가중 수익률. 부분청산 진행 중(closed=False)은 제외하고 개수만 보고.""" + closed = [p for p in positions if p["closed"]] + open_partial = len(positions) - len(closed) + wins = [p for p in closed if p["net_pnl"] > 0] + losses = [p for p in closed if p["net_pnl"] < 0] + gp = sum(p["net_pnl"] for p in wins) + gl = sum(p["net_pnl"] for p in losses) + if gl < 0: + pf = gp / abs(gl) + else: + pf = float("inf") if gp > 0 else 0.0 + wr = [p["return_pct"] for p in wins if p["return_pct"] is not None] + lr = [p["return_pct"] for p in losses if p["return_pct"] is not None] + cost_total = sum(p["cost"] for p in closed if p["cost"]) + net_total = sum(p["net_pnl"] for p in closed if p["cost"]) + # [2026-10-09 R2] 금액 기준 평균손익·손익분기 승률. 레그(SELL 행) 단위 승률은 분할익절로 부풀어 + # 주 지표로 쓰지 않는다. 손익분기 승률 = 평균손실/(평균이익+평균손실). + avg_win_amt = (gp / len(wins)) if wins else 0.0 + avg_loss_amt = (abs(gl) / len(losses)) if losses else 0.0 + be_wr = (avg_loss_amt / (avg_win_amt + avg_loss_amt) * 100.0 + if (avg_win_amt + avg_loss_amt) > 0 else None) + # 수동 청산이 닫은(일부라도 수동 매도가 섞인) 봇 포지션은 별도 열로 귀속 + man = [p for p in closed if p.get("manual_exits", 0) > 0] + sysx = [p for p in closed if p.get("manual_exits", 0) == 0] + return { + "avg_win_amount": round(avg_win_amt, 2), + "avg_loss_amount": round(-avg_loss_amt, 2), + "payoff_ratio_amount": round(avg_win_amt / avg_loss_amt, 4) if avg_loss_amt > 0 else None, + "breakeven_win_rate": round(be_wr, 2) if be_wr is not None else None, + "manual_exit_positions": len(man), + "manual_exit_net_pnl": round(sum(p["net_pnl"] for p in man), 2), + "system_exit_positions": len(sysx), + "system_exit_net_pnl": round(sum(p["net_pnl"] for p in sysx), 2), + "positions": len(closed), + "open_partial_positions": open_partial, + "win_positions": len(wins), + "loss_positions": len(losses), + "win_rate": round(len(wins) / len(closed) * 100, 2) if closed else 0.0, + "profit_factor": round(min(pf, 99.0), 4), + "net_pnl": round(sum(p["net_pnl"] for p in closed), 2), + "avg_win_pct": round(sum(wr) / len(wr), 4) if wr else 0.0, + "avg_loss_pct": round(sum(lr) / len(lr), 4) if lr else 0.0, + # 금액 가중: sum(순손익)/sum(원가). 단순 평균(%)은 소액 대박에 끌려가므로 병기. + "amount_weighted_return_pct": round(net_total / cost_total * 100.0, 4) if cost_total > 0 else None, + "avg_holding_days": ( + round(sum(p["holding_days"] for p in closed if p["holding_days"] is not None) + / max(1, sum(1 for p in closed if p["holding_days"] is not None)), 2) + if closed else None + ), + } + + +def _group_positions_v2(positions: List[Dict[str, Any]], keyfn) -> Dict[str, Dict[str, Any]]: + groups: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + for p in positions: + if not p["closed"]: + continue + groups[str(keyfn(p))].append(p) + out = {} + for k, ps in groups.items(): + st = _position_stats_v2(ps) + out[k] = { + "positions": st["positions"], + "win_rate": st["win_rate"], + "net_pnl": st["net_pnl"], + "profit_factor": st["profit_factor"], + "amount_weighted_return_pct": st["amount_weighted_return_pct"], + } + return out + + +def _summary_from_trades_v2(trades: List[Any]) -> Dict[str, Any]: + """analyze_performance 의 summary 와 같은 정의(SELL 행 단위, 금액 기준 PF)를 임의 SELL 목록에 적용.""" + if not trades: + return _empty_summary() + pl_rates = [_to_float(t.profit_loss_rate) for t in trades] + pl_amounts = [_to_float(t.net_profit_loss) for t in trades] + wins = [r for r in pl_rates if r > 0] + losses = [r for r in pl_rates if r < 0] + avg_win = sum(wins) / len(wins) if wins else 0.0 + avg_loss = sum(losses) / len(losses) if losses else 0.0 + gp = sum(a for a in pl_amounts if a > 0) + gl = sum(a for a in pl_amounts if a < 0) + if gl < 0: + pf = gp / abs(gl) + else: + pf = float("inf") if gp > 0 else 0.0 + return { + "total_trades": len(trades), + "win_trades": len(wins), + "loss_trades": len(losses), + "win_rate": round(len(wins) / len(pl_rates) * 100, 2), + "avg_win_pct": round(avg_win, 4), + "avg_loss_pct": round(avg_loss, 4), + "profit_factor": round(min(pf, 99.0), 4), + "gross_profit": round(gp, 2), + "gross_loss": round(gl, 2), + "net_pnl": round(gp + gl, 2), + "max_consecutive_loss": _calc_max_consecutive_loss(pl_rates), + "risk_reward_ratio": round(abs(avg_win / avg_loss), 4) if avg_loss != 0 else 0.0, + **_cost_adjustment_v2(trades), + } + + +def _krx_trading_days_v2(start: date, end: date) -> List[date]: + from app.core.market_schedule import is_krx_holiday + days: List[date] = [] + d = start + while d <= end: + if d.weekday() < 5 and not is_krx_holiday(d): + days.append(d) + d += timedelta(days=1) + return days + + +def _daily_returns_v2( + daily_pnl: Dict[date, float], + trading_days: List[date], + capital_by_day: Optional[Dict[date, float]] = None, + fallback_capital: float = _FALLBACK_CAPITAL_KRW, +) -> Dict[str, Any]: + """KRX 거래일 전체(무거래일=0)를 인덱스로 일별 수익률을 만든다. + + 분모: 그날 이전(포함) 가장 최근 세션 starting_balance(실제 총자산) — 없으면 fallback_capital + 에 누적 실현손익을 더한 값(구 동작). 거래일이 아닌 날의 PnL 이 있으면 그날도 인덱스에 포함. + """ + days = sorted(set(trading_days) | set(daily_pnl.keys())) + cap_days = sorted((capital_by_day or {}).keys()) + returns: List[float] = [] + equity = fallback_capital + real_cap_days = 0 + ci = 0 + cur_cap: Optional[float] = None + for d in days: + while ci < len(cap_days) and cap_days[ci] <= d: + v = capital_by_day[cap_days[ci]] + if v and v > 0: + cur_cap = v + ci += 1 + pnl = daily_pnl.get(d, 0.0) + base = cur_cap if cur_cap else equity + if cur_cap: + real_cap_days += 1 + if base <= 0: + break + returns.append(pnl / base) + equity += pnl + n = len(days) + return { + "returns": returns, + "days": n, + "active_days": sum(1 for d in days if d in daily_pnl and daily_pnl[d] != 0.0), + "capital_source": ( + "session_starting_balance" if real_cap_days == n and n > 0 + else ("mixed" if real_cap_days > 0 else "fallback_fixed_capital") + ), + } + + +def _sharpe_ci95_v2(sharpe: Optional[float], n: int) -> Optional[List[float]]: + """Lo(2002) 근사 표준오차 기반 95% 신뢰구간(연환산 Sharpe). 표본 부족이면 None.""" + if sharpe is None or n < 2: + return None + sr_d = sharpe / _ANNUALIZE_FACTOR + se = math.sqrt((1.0 + 0.5 * sr_d ** 2) / n) * _ANNUALIZE_FACTOR + return [round(sharpe - 1.96 * se, 4), round(sharpe + 1.96 * se, 4)] + + +def _risk_metrics_v2( + trades: List[Any], + trading_days: List[date], + capital_by_day: Optional[Dict[date, float]] = None, +) -> Dict[str, Any]: + daily_pnl = _build_daily_pnl_series(trades) + dr = _daily_returns_v2(daily_pnl, trading_days, capital_by_day) + r = dr["returns"] + sharpe = _calc_sharpe_ratio(r) + adj_daily: Dict[date, float] = defaultdict(float) + for t in trades: + ca = getattr(t, "created_at", None) + if ca is None: + continue + adj_daily[ca.date() if isinstance(ca, datetime) else ca] += _adjusted_net_v2(t) + adj_r = _daily_returns_v2(dict(adj_daily), trading_days, capital_by_day)["returns"] + return { + "sharpe_ratio_cost_adjusted": _calc_sharpe_ratio(adj_r), + "sharpe_ratio": sharpe, + "sharpe_ci95": _sharpe_ci95_v2(sharpe, len(r)), + "sortino_ratio": _calc_sortino_ratio(r), + "calmar_ratio": _calc_calmar_ratio(r), + "daily_returns_count": len(r), + "active_trading_days": dr["active_days"], + "capital_source": dr["capital_source"], + "low_sample": dr["active_days"] < _LOW_SAMPLE_ACTIVE_DAYS, + "_returns": r, + } + + +def _detect_drift_alerts_v2(trades: List[Any], daily_returns: List[float]) -> List[Dict[str, Any]]: + """_detect_drift_alerts 의 승률/연패 경보는 유지하고, Sharpe/MDD 경보는 0 채움 시계열로 재계산.""" + alerts = [a for a in _detect_drift_alerts(trades) + if a.get("type") not in ("rolling_sharpe", "mdd_acceleration")] + recent = daily_returns[-_DRIFT_ROLLING_WINDOW:] + if len(recent) >= _DRIFT_ROLLING_WINDOW: + rs = _calc_sharpe_ratio(recent) + if rs is not None and rs <= _DRIFT_SHARPE_THRESHOLD: + alerts.append({ + "type": "rolling_sharpe", "value": rs, "threshold": _DRIFT_SHARPE_THRESHOLD, + "message": f"최근 {_DRIFT_ROLLING_WINDOW}거래일 Sharpe {rs:.2f} (임계값 {_DRIFT_SHARPE_THRESHOLD:.1f})", + }) + if len(daily_returns) >= 5: + equity = 1.0 + eqs = [equity] + for r in daily_returns: + equity *= (1.0 + r) + eqs.append(equity) + recent_eq = eqs[-6:] + peak, low = max(recent_eq), min(recent_eq) + if peak > 0: + dd = (low - peak) / peak + if dd <= _DRIFT_DRAWDOWN_THRESHOLD: + alerts.append({ + "type": "mdd_acceleration", "value": round(dd, 4), "threshold": _DRIFT_DRAWDOWN_THRESHOLD, + "message": f"최근 drawdown {dd*100:.2f}% (임계값 {_DRIFT_DRAWDOWN_THRESHOLD*100:.1f}%)", + }) + return alerts + + +def _hour_kst_v2(ts) -> Optional[str]: + """DB 타임스탬프는 KST naive 로 통일돼 있다(timeutil). tz-aware 면 KST 로 변환.""" + if ts is None or not isinstance(ts, datetime): + return None + if ts.tzinfo is not None: + from app.core.timeutil import KST + ts = ts.astimezone(KST) + return f"{ts.hour:02d}" + + +def _hour_stats_v2(groups: Dict[str, List[Any]]) -> Dict[str, Dict[str, Any]]: + out: Dict[str, Dict[str, Any]] = {} + for h in sorted(groups): + ts = groups[h] + nets = [_to_float(t.net_profit_loss) for t in ts] + adj = [_adjusted_net_v2(t) for t in ts] + out[h] = { + "count": len(ts), + "win_rate": round(sum(1 for a in adj if a > 0) / len(ts) * 100, 2), + "net_pnl": round(sum(nets), 2), + "estimated_cost_adjusted_net": round(sum(adj), 2), + } + return out + + +def _compose_performance_v2( + sells: List[Any], + buys: List[Any], + trading_days: List[date], + capital_by_day: Optional[Dict[date, float]], + days: int, +) -> Dict[str, Any]: + """DB 비의존 순수 조립부(테스트 대상).""" + system_sells = [t for t in sells if _is_system_trade_v2(t)] + external_sells = [t for t in sells if not _is_system_trade_v2(t)] + + # [2026-10-09 R1] 수동 매도가 시스템 BUY lot 을 소진하면 그 포지션은 시스템 포지션에 'manual_exit' + # 로 귀속한다(이전에는 시스템 SELL 만 매칭해 lot 이 영구 open 으로 남았다). 전체 SELL 을 한 번에 + # 매칭해야 lot 소진 순서가 실제와 같다. + all_matches = _match_roundtrips_v2(buys, sells) + + def _buy_is_system(m) -> bool: + return m["buy"] is not None and _is_system_trade_v2(m["buy"]) + + sys_matches = [m for m in all_matches + if _buy_is_system(m) or (m["buy"] is None and _is_system_trade_v2(m["sell"]))] + ext_matches = [m for m in all_matches if m not in sys_matches] + matches = sys_matches + positions = _build_positions_v2(sys_matches) + ext_positions = _build_positions_v2(ext_matches) + manual_exit_sells = [m["sell"] for m in sys_matches if not _is_system_trade_v2(m["sell"])] + + sys_summary = _summary_from_trades_v2(system_sells) + risk = _risk_metrics_v2(system_sells, trading_days, capital_by_day) + daily_returns = risk.pop("_returns") + + strat_stats: Dict[str, Any] = {} + by_strat_rows: Dict[str, List[float]] = defaultdict(list) + for t in system_sells: + by_strat_rows[_parse_strategy(t.entry_reason, getattr(t, "strategy_type", None))].append( + _to_float(t.profit_loss_rate)) + for k, rates in by_strat_rows.items(): + strat_stats[k] = { + "total": len(rates), + "win_rate": round(len([r for r in rates if r > 0]) / len(rates) * 100, 2), + "avg_return": round(sum(rates) / len(rates), 4), + } + exit_rows: Dict[str, List[float]] = defaultdict(list) + for t in system_sells: + exit_rows[_parse_exit_type(t.exit_reason)].append(_to_float(t.profit_loss_rate)) + exit_stats = { + k: {"count": len(v), "avg_return": round(sum(v) / len(v), 4), + "win_rate": round(len([r for r in v if r > 0]) / len(v) * 100, 2)} + for k, v in exit_rows.items() if v + } + + sys_sell_matches = [m for m in sys_matches if _is_system_trade_v2(m["sell"])] + linked = sum(1 for m in sys_sell_matches if m["buy"] is not None) + explicit = sum(1 for m in sys_sell_matches if m.get("linked_by") == "entry_trade_id") + fifo = sum(1 for m in sys_sell_matches if m.get("linked_by") == "fifo") + n_sys = len(system_sells) + + # 시간대(KST naive) 귀인: 청산 시각 / 진입(매칭된 BUY) 시각, 비용 추정 차감 net 병기 + by_exit_hour: Dict[str, List[Any]] = defaultdict(list) + by_entry_hour: Dict[str, List[Any]] = defaultdict(list) + for m in sys_sell_matches: + sl = m["sell"] + eh = _hour_kst_v2(getattr(sl, "created_at", None)) + if eh is not None: + by_exit_hour[eh].append(sl) + if m["buy"] is not None: + nh = _hour_kst_v2(getattr(m["buy"], "created_at", None)) + if nh is not None: + by_entry_hour[nh].append(sl) + return { + "summary": sys_summary, # 시스템(manual/external 제외) SELL(레그) 단위 — 주 지표 아님 + "summary_basis": "sell_leg", # 분할익절 레그 단위. 주 지표는 position_summary + "summary_all": _summary_from_trades_v2(sells), # 참고: 수동·외부 포함 + "external_summary": _summary_from_trades_v2(external_sells), + "position_summary": _position_stats_v2(positions), + "external_position_summary": _position_stats_v2(ext_positions), + "by_strategy": strat_stats, + "by_strategy_position": _group_positions_v2(positions, lambda p: p["strategy"]), + "by_entry_reason": _group_positions_v2(positions, lambda p: p["entry_reason"] or "unknown"), + "by_holding_period": _group_positions_v2(positions, lambda p: _holding_bucket_v2(p["holding_days"])), + "by_exit_type": exit_stats, + "roundtrip": { + "sells": n_sys, + "matched_to_buy": linked, + "unmatched_sells": n_sys - linked, + "linked_by_entry_trade_id": explicit, + "linked_by_fifo_estimate": fifo, + # FIFO 추정 비중이 높으면 보유기간·진입사유 귀인은 추정치다(운영 SELL 의 entry_trade_id 는 전부 NULL). + "attribution_confidence": ( + "high" if n_sys and explicit / n_sys >= 0.8 + else ("low_fifo_estimate" if n_sys else "n/a")), + "method": "entry_trade_id 우선, 없으면 종목별 FIFO(동일 strategy_type 우선)", + }, + "manual_exit_summary": { + **_summary_from_trades_v2(manual_exit_sells), + "note": "시스템 BUY lot 을 청산한 수동 매도(시스템 SELL summary 에는 미포함, 포지션에는 manual_exit 로 귀속)", + }, + "by_exit_hour": _hour_stats_v2(by_exit_hour), + "by_entry_hour": _hour_stats_v2(by_entry_hour), + **risk, + "drift_alerts": _detect_drift_alerts_v2(system_sells, daily_returns), + "period_days": days, + "total_trades": len(system_sells), + } + + +async def analyze_performance_v2(db: AsyncSession, days: int = 30) -> Dict[str, Any]: + """analyze_performance 의 보정판. summary 는 manual/external 을 제외한 시스템 성과이며, + 포지션(라운드트립) 단위 통계·보유기간/진입사유별 귀인·0 채움 Sharpe 를 추가로 반환한다. + build_insights() 와 호환(summary/by_strategy/by_exit_type/drift_alerts/total_trades 키 유지).""" + from app.models.models import TradingSession + + now = now_kst() + since = now - timedelta(days=days) + sells_res = await db.execute( + select(TradeHistory).where(and_( + TradeHistory.order_type == "SELL", + TradeHistory.status.in_(["COMPLETED", "FILLED"]), + TradeHistory.created_at >= since, + )).order_by(TradeHistory.created_at) + ) + sells: List[TradeHistory] = sells_res.scalars().all() + + buys: List[TradeHistory] = [] + if sells: + buys_res = await db.execute( + select(TradeHistory).where(and_( + TradeHistory.order_type == "BUY", + TradeHistory.status.in_(["COMPLETED", "FILLED", "PARTIAL"]), + TradeHistory.created_at >= since - timedelta(days=_ROUNDTRIP_BUY_LOOKBACK_DAYS), + )).order_by(TradeHistory.created_at) + ) + buys = buys_res.scalars().all() + + capital_by_day: Dict[date, float] = {} + sess_res = await db.execute( + select(TradingSession.session_date, TradingSession.starting_balance) + .where(TradingSession.session_date >= since.date()) + ) + for d, bal in sess_res.all(): + if d is not None and bal is not None and float(bal) > 0: + capital_by_day[d] = float(bal) + + trading_days = _krx_trading_days_v2(since.date(), now.date()) + perf = _compose_performance_v2(sells, buys, trading_days, capital_by_day, days) + perf["generated_at"] = now.isoformat() + return perf + + def _empty_summary() -> Dict[str, Any]: return { "total_trades": 0, @@ -607,6 +1233,9 @@ def _empty_summary() -> Dict[str, Any]: "net_pnl": 0.0, "max_consecutive_loss": 0, "risk_reward_ratio": 0.0, + "gross_basis_trades": 0, + "estimated_cost": 0.0, + "estimated_cost_adjusted_net": 0.0, } @@ -639,6 +1268,27 @@ def build_insights(perf: Dict[str, Any]) -> List[Dict[str, str]]: max_cons_loss = summary.get("max_consecutive_loss", 0) rr = summary.get("risk_reward_ratio", 0.0) + # [2026-10-09 R2] 포지션(BUY→전량청산) 단위 집계가 있으면 그것을 주 지표로 쓴다. SELL 레그 단위 + # 승률/손익비는 분할익절 레그가 '승'을 부풀려 손익분기 승률을 과대 평가하게 만든다. + pos = perf.get("position_summary") or {} + pos_based = bool(pos.get("positions")) + if pos_based: + win_rate = pos.get("win_rate", 0.0) + pf = pos.get("profit_factor", 0.0) + avg_loss = pos.get("avg_loss_pct", 0.0) + _aw = pos.get("avg_win_pct", 0.0) + rr = abs(_aw / avg_loss) if avg_loss else 0.0 + _be = pos.get("breakeven_win_rate") + insights.append({"level": "info", "message": ( + f"[포지션 단위 {pos['positions']}건] 승률 {win_rate:.1f}%" + + (f" / 손익분기 승률 {_be:.1f}%" if _be is not None else "") + + f" / PF {pf:.2f} (레그 단위 승률 {summary.get('win_rate', 0.0):.1f}% 는 참고용)")}) + if pos.get("manual_exit_positions"): + insights.append({"level": "info", "message": ( + f"수동 청산이 닫은 봇 포지션 {pos['manual_exit_positions']}건 순손익 " + f"{pos.get('manual_exit_net_pnl', 0):,.0f}원 / 시스템 청산 {pos.get('system_exit_positions', 0)}건 " + f"{pos.get('system_exit_net_pnl', 0):,.0f}원 — 봇 성과 판단 시 분리해서 볼 것")}) + if win_rate < 40: insights.append({"level": "danger", "message": f"승률 {win_rate:.1f}% — RSI 과매도 기준을 강화하거나 진입 조건을 추가 필터링하세요."}) elif win_rate < 50: diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 0aa76a5..637a6e7 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -9,11 +9,12 @@ """ import asyncio import logging +import re import time from decimal import Decimal from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc, and_, or_ +from sqlalchemy import select, desc, and_, or_, func from app.core.config import settings from app.core.database import AsyncSessionLocal @@ -770,6 +771,46 @@ def _parse_scale_v2(raw, name: str, stock_code: str) -> float: return max(0.0, min(1.0, val)) +def _dt_invested_amount_v2(stock_code: str) -> float: + """데이트레이딩 인메모리 포지션 투자액 합계(진입가×수량). + + [R6-2 2026-10-10] 예외를 로그 없이 0.0 으로 삼키면 스윙 투자액이 DT 포지션까지 + 포함해 부풀려지고(보수 방향), 원인 추적 불가능한 swing_capital_cap 차단이 났다. + 값은 이전과 같이 0.0 폴백(보수 방향 유지)이되 warning 을 남긴다(§4). + """ + try: + from app.services.daytrading.position_manager import day_trading_state as _dts + return sum( + float(p.get("entry_price", 0) or 0) * int(p.get("quantity", 0) or 0) + for p in _dts.positions.values() + ) + except Exception as e: + logger.warning( + f"[TRADE] {stock_code}: DT 투자액 집계 실패 — 0 으로 간주" + f"(스윙 투자액 과대 → 스윙 잔여 한도 보수 산정): {e}" + ) + return 0.0 + + +def _reapply_capital_guards_v2( + invest_amount: float, + available_cash: float, + min_cash_reserve: float, + swing_room: float | None, +) -> float: + """패턴메모리 조정 이후 현금 유보(§5.1 95%)·스윙 자본 상한(70%) 재적용. + + [R6-1 2026-10-10] 두 가드가 패턴메모리(최대 1.5배) **이전**에만 적용돼, 조정이 + 한도 끝까지 줄인 금액을 되돌리면 4% 재클램프만으로는 두 상한을 넘을 수 있었다. + 리스크 가드이므로 호출부는 try 밖에서 무조건 호출한다(§4). swing_room=None 은 + 스윙 상한 미적용(비율 ≥1 또는 총자산 0) 경로다. + """ + bounded = min(float(invest_amount), max(0.0, float(available_cash) - float(min_cash_reserve))) + if swing_room is not None: + bounded = min(bounded, max(0.0, float(swing_room))) + return max(0.0, bounded) + + # ── 지정가 조정 — 체결률 향상 ────────────────────────────────────────────────── def _resolve_sell_order_price_v2( stock_code: str, current_price: float, is_domestic: bool, full_exit: bool, profit_loss_rate: float, @@ -843,7 +884,9 @@ async def _calculate_kelly_position( TradeHistory.status == "COMPLETED", TradeHistory.profit_loss_rate.isnot(None), ) - .order_by(desc(TradeHistory.executed_at)) + # [하드닝 R1 2026-10-09] executed_at 단독 DESC 는 NULL(스윙 청산·DT·MDD SELL)을 + # PostgreSQL 에서 맨 앞에 둬 '최근 N거래'가 시간순이 아니었다 → coalesce 로 정렬. + .order_by(desc(_fill_or_created_at_v2()), desc(TradeHistory.id)) .limit(lookback) ) trades = recent.scalars().all() @@ -939,6 +982,108 @@ async def _mark_trade_cancelled_v2(order_id: str, db_trade_id: int, filled_qty: await enqueue_update_trade_history_by_order_id(order_id, fields) +# ── [하드닝 R7 2026-10-10] 불성립 BUY 의 ActiveTrade 원복 ───────────────────────── +# 운영 9/28: 스윙 BUY #324(352820)·#325(006360)가 TradeHistory 에 COMPLETED·전량으로 남았으나 +# ActiveTrade 는 17~32초 만에 닫혔고(동기화의 유령 행 정리로 추정) synced 재시딩·SELL·보유가 모두 +# 없다. 같은 날 체결된 매수(005490·015760·033780)는 전부 synced 로 재시딩됐으므로 두 건은 +# 0체결 소멸로 추정된다(KIS 체결조회로 운영자 확정 필요). 당시엔 체결조회 경로 자체가 없어 +# (§8.5-27, 10/01 수정) CANCELLED 강등도 불가했다. +# 현재 코드는 0체결·잔량 소멸이면 TradeHistory 를 CANCELLED 로 강등하지만, 주문 시점에 만든 +# 스윙 ActiveTrade 는 그대로 active 로 남겨 sync 의 유령 정리에 맡겼다(장부 불일치 창 + 그 사이 +# 청산 판정이 존재하지 않는 포지션을 본다). 부분체결·잔량 소멸도 ActiveTrade 수량이 주문 수량이었다. +# +# 원복 정보는 v3 시그니처를 바꾸지 않도록 주문번호 키 레지스트리로 넘긴다(§4 시그니처 보존). +# 등록하지 않은 호출부(수동 매수·매도·결과 불명 resolver)는 동작 불변. +_buy_entry_rollbacks: dict[str, dict] = {} + + +def register_buy_entry_rollback_v2( + order_id, stock_code: str, strategy_name: str, quantity: int, accumulate: bool, +) -> None: + """BUY 주문이 만든 ActiveTrade 의 원복 정보를 등록한다(당일, 프로세스 메모리).""" + key = _entry_fill_key_v2(order_id) + if not key or not strategy_name: + return + today = _get_kst_now().date().isoformat() + # 체결 확인이 목킹·예외로 소비하지 못한 지난 날짜 항목 정리(무한 증가 방지) + for k in [k for k, v in _buy_entry_rollbacks.items() if v.get("day") != today]: + _buy_entry_rollbacks.pop(k, None) + _buy_entry_rollbacks[key] = { + "day": today, + "stock_code": stock_code, + "strategy_name": strategy_name, + "quantity": int(quantity), + "accumulate": bool(accumulate), + } + + +def _pop_buy_entry_rollback_v2(order_id, stock_code: str) -> dict | None: + rb = _buy_entry_rollbacks.pop(_entry_fill_key_v2(order_id), None) + if not rb or rb.get("stock_code") != stock_code: + return None # 다른 종목 키 충돌(주문번호는 일별 순번) — 원복하지 않는다 + if rb.get("day") != _get_kst_now().date().isoformat(): + return None + return rb + + +def _notify_buy_ledger_v2(message: str) -> None: + try: + from app.services.telegram_notifier import telegram_notifier + spawn_tracked_task_v2(telegram_notifier.notify_system_warning("BUY_LEDGER", message)) + except Exception as _tg_err: + logger.warning(f"[BUY_LEDGER] 텔레그램 통지 실패: {_tg_err}") + + +async def _rollback_buy_active_trade_v2( + order_id: str, rollback: dict | None, filled_qty: int, +) -> str: + """불성립(0체결)·부분체결 확정 BUY 의 ActiveTrade 를 실제 체결 기준으로 원복(db_worker 큐). + + 반환: 'none'(등록 없음) | 'closed' | 'resized' | 'manual'(추가매수 — 수동 확인) | 'noop'. + + - 신규 진입 + 0체결 → 그 전략명(동등) active 행 close. 이미 sync 가 닫았으면 워커가 no-op. + - 신규 진입 + 부분체결 확정 → quantity 를 체결수량으로(entry_price 키 없음 → 행이 없으면 + 워커가 생성하지 않고 skip). 체결가 보정(entry_fill_adjust)보다 **먼저** 큐잉해야 평단이 체결가가 된다. + - 추가매수(accumulate) → 가중평균·remaining_pct 재계산을 역산할 근거가 없어 원복하지 않는다. + error + 텔레그램으로 수동 확인을 요청한다(과다 수량이 남으면 분할매도가 과다매도 시도 → + 매도 직전 KIS 잔고 상한이 막지만 장부는 틀린 상태). + """ + if not rollback: + return "none" + code = rollback["stock_code"] + strategy_name = rollback["strategy_name"] + ordered = int(rollback.get("quantity") or 0) + filled = max(0, int(filled_qty or 0)) + if ordered > 0 and filled >= ordered: + return "noop" + if rollback.get("accumulate"): + logger.error( + f"[BUY_LEDGER] {code} 추가매수 {ordered}주 중 {filled}주만 체결 확정(order_id={order_id}) — " + f"ActiveTrade({strategy_name}) 누적 수량·평단 자동 원복 불가, 수동 확인 필요" + ) + _notify_buy_ledger_v2( + f"{code} 추가매수 {filled}/{ordered}주 체결 확정 — ActiveTrade({strategy_name}) 수동 정정 필요" + ) + return "manual" + if filled <= 0: + from app.services.db_worker import enqueue_close_active_trade + await enqueue_close_active_trade(code, strategy_name) + logger.warning( + f"[BUY_LEDGER] {code} 매수 0체결 소멸(order_id={order_id}) — " + f"ActiveTrade({strategy_name}) close 큐잉" + ) + return "closed" + from app.services.db_worker import enqueue_active_trade_upsert + await enqueue_active_trade_upsert({ + "stock_code": code, "strategy_name": strategy_name, "quantity": filled, + }) + logger.warning( + f"[BUY_LEDGER] {code} 매수 부분체결 확정 {filled}/{ordered}주(order_id={order_id}) — " + f"ActiveTrade({strategy_name}) 수량 {ordered}→{filled}" + ) + return "resized" + + async def _verify_and_sync_execution( order_id: str, stock_code: str, @@ -978,7 +1123,667 @@ async def _verify_and_sync_execution_v2( rollback_active_trade: dict | None = None, order_org_no: str | None = None, ) -> None: - """[H-1 5회차] cancel_unfilled_limit=True: 폴링 종료 시 국내 SELL 잔량이 살아 있으면 취소. + """기존 시그니처 유지 래퍼 — 본체는 `_verify_and_sync_execution_v3`.""" + await _verify_and_sync_execution_v3( + order_id, stock_code, stock_name, ordered_quantity, order_price, order_type, + is_domestic, exchange_code, db_trade_id, delay=delay, + cancel_unfilled_limit=cancel_unfilled_limit, + rollback_active_trade=rollback_active_trade, + order_org_no=order_org_no, + ) + + +# [2026-10-08] ActiveTrade 진입가 체결가 보정을 이미 큐잉한 BUY 주문번호(당일, 프로세스 메모리). +# swing/fill_sweep 이 같은 주문을 이중 보정하지 않게 한다(TradeHistory 갱신만 유실된 경우 등). +_entry_fill_adjusted_orders: dict[str, str] = {} + + +def _entry_fill_key_v2(order_id) -> str: + return str(order_id or "").strip().lstrip("0") + + +def note_entry_fill_adjusted_v2(order_id) -> None: + key = _entry_fill_key_v2(order_id) + if not key: + return + today = _get_kst_now().date().isoformat() + for k in [k for k, d in _entry_fill_adjusted_orders.items() if d != today]: + _entry_fill_adjusted_orders.pop(k, None) + _entry_fill_adjusted_orders[key] = today + + +def entry_fill_already_adjusted_v2(order_id) -> bool: + key = _entry_fill_key_v2(order_id) + return bool(key) and _entry_fill_adjusted_orders.get(key) == _get_kst_now().date().isoformat() + + +# ── [하드닝 R3 2026-10-09] 기존 스윙 ActiveTrade 진입가 1회성 백필 ───────────────── +# 진입 upsert 는 주문 지정가(현재가×~1.005)를 entry_price 로 기록한다. 체결 확인 시 보정 +# (entry_fill_adjust / swing.fill_sweep)은 **앞으로의 체결**과 **당일·미확인 행**에만 적용돼, +# 이미 체결가가 확정된 과거 BUY 의 ActiveTrade 는 주문가로 남는다(운영 active 5행 +0.3~0.8%). +# 그 결과 손절이 실제 평단 기준 -7% 가 아니라 약 -6.3% 에서, 1차 익절 최소수익 가드가 실제로는 +# 약 1.3% 에서 걸리고 SELL net_profit_loss 가 과소 기록된다. +# 아래 함수들이 "확정 가능한 행만" 골라 db_worker 의 entry_fill_adjust 경로로 보정한다. +# 확정할 수 없으면 보정하지 않고 사유를 돌려준다(엉뚱한 행의 진입가를 움직이지 않는다). + +# ActiveTrade 생성 시각보다 이 초 이전까지의 BUY 를 '그 행을 만든 매수'로 본다 +# (executor 는 TradeHistory INSERT 직후 ActiveTrade upsert 를 큐잉한다 — 운영 실측 차이 <0.1초). +_ENTRY_BACKFILL_MATCH_WINDOW_SEC = 120 +# Numeric(15,2) 반올림·가중평균 누적 반올림 허용 오차(원) +_ENTRY_BACKFILL_PRICE_TOL = 0.05 +_ENTRY_BACKFILL_SWING_BUY_TYPES = (None, "", "swing") +_ENTRY_BACKFILL_SEEDED_NAMES = ("synced",) +_ENTRY_BACKFILL_SEEDED_REASON_PREFIXES = ("동기화 유입", "외부 유입 포지션") + + +def _entry_backfill_is_seeded_v2(row) -> bool: + name = (getattr(row, "strategy_name", None) or "").strip() + reason = (getattr(row, "entry_reason", None) or "").strip() + return name in _ENTRY_BACKFILL_SEEDED_NAMES or reason.startswith(_ENTRY_BACKFILL_SEEDED_REASON_PREFIXES) + + +def _entry_backfill_fill_confirmed_v2(th) -> bool: + """swing.fill_sweep.unconfirmed_fill_clause_v2 의 부정(파이썬 판정, 같은 정의). + + 미확인 = 체결가 NULL, 또는 레거시 규약(체결가=주문가 대체 + slippage NULL). + """ + ep = getattr(th, "executed_price", None) + if ep is None: + return False + op = getattr(th, "order_price", None) + if getattr(th, "slippage", None) is None and op is not None and float(ep) == float(op): + return False + return True + + +def plan_swing_entry_backfill_v2(active_row, same_stock_active_rows, buy_rows) -> tuple[dict | None, str]: + """기존 스윙 ActiveTrade 1행의 진입가 백필 payload 를 계산한다 (순수 함수, I/O 없음). + + Args: + active_row: 대상 ActiveTrade(status=active). + same_stock_active_rows: 같은 종목의 active ActiveTrade 전체(대상 포함) — db_worker 가 + 전략명으로 고를 행(같은 전략명 중 id 최대)이 대상과 같은지 확인용. + buy_rows: 같은 종목 TradeHistory BUY 후보(필터 전이어도 된다 — 여기서 거른다). + + Returns: + (payload | None, 사유). payload 는 `enqueue_active_trade_upsert` 에 그대로 넘긴다. + 보정 방식은 entry_fill_adjust 델타(order_price=현재 entry_price 스냅샷, filled_price=목표, + filled_qty=행 수량)라서, 큐 처리 시점의 행이 스냅샷과 같으면 정확히 목표가가 되고 + 두 번 실행해도 두 번째는 델타 0 이다(재계산 시 이미 보정됨으로 skip). + """ + from app.services.db_worker import DT_STRATEGY_NAMES + + name = (getattr(active_row, "strategy_name", None) or "").strip() + code = getattr(active_row, "stock_code", None) + if getattr(active_row, "status", "active") != "active": + return None, "active 아님" + if not name: + return None, "strategy_name 없음(대상 행 확정 불가)" + if name in DT_STRATEGY_NAMES: + return None, "데이트레이딩 행" + if _entry_backfill_is_seeded_v2(active_row): + return None, "시딩(동기화 유입) 행 — entry_price 가 KIS 평단" + if (getattr(active_row, "currency_code", None) or "KRW") != "KRW": + return None, "해외 행" + + eligible = [ + r for r in (same_stock_active_rows or []) + if getattr(r, "status", "active") == "active" + and (getattr(r, "strategy_name", None) or "").strip() == name + ] + if eligible: + newest = max(eligible, key=lambda r: int(getattr(r, "id", 0) or 0)) + if getattr(newest, "id", None) != getattr(active_row, "id", None): + return None, f"전략명 {name} 의 최신 active 행(id={getattr(newest, 'id', None)})이 대상이 아님" + + try: + cur = float(getattr(active_row, "entry_price", 0) or 0) + rec_q = int(getattr(active_row, "quantity", 0) or 0) + rp = float(getattr(active_row, "remaining_pct", 1.0) if getattr(active_row, "remaining_pct", None) is not None else 1.0) + except (TypeError, ValueError): + return None, "행 수치 파싱 실패" + if cur <= 0 or rec_q <= 0: + return None, "entry_price/quantity 비정상" + if rec_q * min(1.0, max(0.0, rp)) < 1: + return None, "실보유(quantity×remaining_pct) < 1주" + + created = getattr(active_row, "created_at", None) + if created is None: + return None, "ActiveTrade.created_at 없음" + lo = created - timedelta(seconds=_ENTRY_BACKFILL_MATCH_WINDOW_SEC) + buys = sorted( + [ + t for t in (buy_rows or []) + if getattr(t, "stock_code", code) == code + and getattr(t, "order_type", None) == "BUY" + and getattr(t, "status", None) == "COMPLETED" + and getattr(t, "strategy_type", None) in _ENTRY_BACKFILL_SWING_BUY_TYPES + and (getattr(t, "currency_code", None) or "KRW") == "KRW" + and getattr(t, "created_at", None) is not None + and t.created_at >= lo + ], + key=lambda t: (t.created_at, int(getattr(t, "id", 0) or 0)), + ) + if not buys: + return None, "대응 스윙 BUY 없음" + unconfirmed = [getattr(t, "id", None) for t in buys if not _entry_backfill_fill_confirmed_v2(t)] + if unconfirmed: + return None, f"체결가 미확정 BUY {unconfirmed} (레거시 규약 — 수동 확인)" + try: + qs = [int(getattr(t, "executed_quantity", 0) or 0) for t in buys] + ops = [float(getattr(t, "order_price", 0) or 0) for t in buys] + fps = [float(getattr(t, "executed_price", 0) or 0) for t in buys] + except (TypeError, ValueError): + return None, "BUY 수치 파싱 실패" + if any(q <= 0 for q in qs) or any(p <= 0 for p in ops) or any(p <= 0 for p in fps): + return None, "BUY 수량/가격 비정상(부분·0체결 포함)" + if sum(qs) != rec_q: + return None, f"BUY 체결수량 합 {sum(qs)} ≠ 행 수량 {rec_q} (부분체결·수동 개입 — 수동 확인)" + if len(buys) > 1 and rp < 1.0: + return None, "분할 익절 후 추가매수 행 — 가중치 재구성 불가(수동 확인)" + last_reason = (getattr(buys[-1], "entry_reason", None) or "").strip() + row_reason = (getattr(active_row, "entry_reason", None) or "").strip() + if last_reason and row_reason and last_reason != row_reason: + return None, "최신 BUY 의 entry_reason 이 행과 다름(다른 매수의 행)" + + total_q = float(sum(qs)) + target = round(sum(f * q for f, q in zip(fps, qs)) / total_q, 2) + expected_unadjusted = sum(o * q for o, q in zip(ops, qs)) / total_q + if abs(cur - target) <= _ENTRY_BACKFILL_PRICE_TOL: + return None, "이미 체결가 기준" + if abs(cur - expected_unadjusted) > _ENTRY_BACKFILL_PRICE_TOL: + return None, ( + f"현재 진입가 {cur:,.2f} 가 주문가 기준({expected_unadjusted:,.2f})도 체결가 기준({target:,.2f})도 " + f"아님 — 수동 확인" + ) + + payload = { + "stock_code": code, + "strategy_name": name, + "entry_fill_adjust": { + "order_price": cur, + "filled_price": target, + "filled_qty": rec_q, + }, + } + high = getattr(active_row, "highest_since_entry", None) + # 고점이 진입 주문가 그대로(진입 후 갱신된 적 없음)면 함께 내린다. 갱신됐으면 실제 관측 고점이라 유지. + if high is None or abs(float(high) - cur) <= _ENTRY_BACKFILL_PRICE_TOL: + payload["highest_since_entry"] = target + return payload, f"{cur:,.2f} → {target:,.2f}" + + + +async def collect_swing_entry_backfill_v2(db) -> list[dict]: + """운영 active 스윙 ActiveTrade 전체의 백필 계획을 만든다 (SELECT 전용, commit 없음). + + Returns: + [{"active_id", "stock_code", "strategy_name", "entry_price", "payload"|None, "reason"}] + """ + from app.models.models import ActiveTrade + from app.services.db_worker import DT_STRATEGY_NAMES + + rows = (await db.execute( + select(ActiveTrade).where(ActiveTrade.status == "active").order_by(ActiveTrade.id) + )).scalars().all() + by_code: dict[str, list] = {} + for r in rows: + by_code.setdefault(r.stock_code, []).append(r) + plans: list[dict] = [] + for r in rows: + name = (r.strategy_name or "").strip() + if name in DT_STRATEGY_NAMES or _entry_backfill_is_seeded_v2(r): + continue + buys = [] + if r.created_at is not None: + buys = (await db.execute( + select(TradeHistory).where( + TradeHistory.stock_code == r.stock_code, + TradeHistory.order_type == "BUY", + TradeHistory.created_at >= r.created_at - timedelta(seconds=_ENTRY_BACKFILL_MATCH_WINDOW_SEC), + ) + )).scalars().all() + payload, reason = plan_swing_entry_backfill_v2(r, by_code.get(r.stock_code, []), buys) + plans.append({ + "active_id": r.id, + "stock_code": r.stock_code, + "strategy_name": r.strategy_name, + "entry_price": float(r.entry_price) if r.entry_price is not None else None, + "payload": payload, + "reason": reason, + }) + return plans + + +async def apply_swing_entry_backfill_v2(plans: list[dict], submit=None) -> int: + """계획 중 payload 가 있는 것만 db_worker upsert_active_trade 로 제출한다. 반환 = 제출 건수. + + submit 기본값은 `enqueue_active_trade_upsert`(앱 프로세스 안 — 워커가 처리). + 앱 밖 1회성 스크립트는 워커가 없으므로 `db_worker._execute_db_task_impl` 로 같은 핸들러를 + 직접 실행하는 submit 을 넘긴다(backend/backfill_swing_entry_fill.py). + """ + if submit is None: + from app.services.db_worker import enqueue_active_trade_upsert as submit + n = 0 + for p in plans: + payload = p.get("payload") + if not payload: + continue + await submit(dict(payload)) + n += 1 + return n + + +def _fill_update_fields_v2( + order_type: str, + filled_qty: int, + filled_price: float, + slippage_pct: float, + sell_pnl_basis: dict | None, +) -> dict: + """[2026-10-08] 체결 확인 결과 → TradeHistory 갱신 필드 (순수 함수). + + - executed_price·executed_quantity·total_amount(=체결가×체결수량) 를 **함께** 갱신한다. + 이전엔 total_amount 가 주문가×주문수량으로 남아(#448 한화엔진 41,506×9) 매수 합계가 + 주문가 기준이었다. + - 체결가를 모르면(filled_price ≤ 0) 가격·금액 필드를 넣지 않는다 — 주문가로 대체 금지. + - SELL + sell_pnl_basis(entry_price>0) 이면 순손익·비용·수익률을 체결가 기준으로 재계산한다. + net_rate=False 면 profit_loss_rate 는 기존 executor 규약대로 비용 전(gross) 수익률. + """ + fields: dict = { + "executed_quantity": int(filled_qty), + "status": "COMPLETED", + "executed_at": _get_kst_now(), + } + if filled_price and filled_price > 0: + fields["executed_price"] = Decimal(str(round(float(filled_price), 2))) + fields["total_amount"] = Decimal(str(round(float(filled_price) * int(filled_qty), 2))) + fields["slippage"] = Decimal(str(round(slippage_pct, 4))) + basis = sell_pnl_basis or {} + entry = float(basis.get("entry_price") or 0) + if order_type == "SELL" and entry > 0 and filled_qty > 0: + costs = calculate_trade_costs( + entry_price=entry, exit_price=float(filled_price), quantity=int(filled_qty), + is_domestic=bool(basis.get("is_domestic", True)), + market=basis.get("market") or "KOSPI", + ) + gross = (float(filled_price) - entry) * int(filled_qty) + net = gross - float(costs["total_cost"]) + if basis.get("net_rate"): + rate = net / (entry * int(filled_qty)) * 100 + else: + rate = (float(filled_price) / entry - 1.0) * 100 + fields["commission"] = Decimal(str(costs["total_cost"])) + fields["net_profit_loss"] = Decimal(str(round(net, 2))) + fields["profit_loss_rate"] = Decimal(str(round(rate, 4))) + return fields + + +# ── [하드닝 R1 2026-10-09] 슬리피지 부호 규약 ───────────────────────────────── +# TradeHistory.slippage 저장 규약(변경하지 않음 — DT entry_service 와 같은 컬럼을 공유하므로 +# 한쪽만 바꾸면 컬럼 안에 두 규약이 섞인다): +# slippage = (체결가 − 주문가) / 주문가 × 100 [단위 %, side 무관 원시값] +# → BUY 에서 + 는 불리(비싸게 삼), SELL 에서 + 는 유리(비싸게 팜). +# 따라서 BUY/SELL 을 섞어 평균하면 상쇄되어 실행 비용이 과소 보고된다. 분석·로그는 반드시 +# slippage_cost_bp_v2 로 side 보정(불리 = +)한 bp 를 쓸 것. +def slippage_cost_bp_v2(order_type: str, slippage_pct) -> float | None: + """원시 슬리피지(%) → side 보정 비용(bp, 불리 = +). 해석 불가면 None.""" + try: + val = float(slippage_pct) + except (TypeError, ValueError): + return None + if val != val or val in (float("inf"), float("-inf")): + return None + side = str(order_type or "").upper() + if side == "BUY": + sign = 1.0 + elif side == "SELL": + sign = -1.0 + else: + return None + return sign * val * 100.0 + + +# ── [하드닝 R1 2026-10-09] ActiveTrade 손절·익절 컬럼 = 절대가 ────────────────── +def _swing_active_trade_levels_v2(entry_price, atr, take_profit_pct) -> tuple[float | None, float | None]: + """스윙 ActiveTrade.stop_loss / take_profit 에 저장할 **절대가**를 계산한다. + + 이전엔 추천 actions 의 퍼센트(-1.50 / 3.00)를 그대로 넣어 같은 컬럼에 퍼센트와 절대가 + (sync 시딩·DT)가 섞였고, 저장된 -1.5% 는 실제 손절선(적응형 → strategy_cfg → config)과 + 무관했다. 스윙 청산 판정은 이 컬럼을 읽지 않으므로(매매 동작 무변경) 표시·감사· + 재시딩 승계(portfolio.py: `> 0` 일 때만 승계 = 가격 규약)용이다. + + - stop_loss: swing/exit_service 와 같은 식 — resolve_swing_stop_params_v2 의 손절률(항상 음수) + 로 만든 가격과 ATR 손절가(진입가 − 배수×ATR) 중 **높은 쪽**(가격 기준 타이트, §5.2). + 해석기 예외 시 config STOP_LOSS_PERCENT 로 폴백(-abs 부호 가드는 try 밖). + - take_profit: 호출부가 준 퍼센트를 절대가로 환산만 한다(값 무변경). 없거나 0 이하면 None. + """ + try: + entry = float(entry_price) + except (TypeError, ValueError): + return None, None + if not (entry > 0): + return None, None + try: + atr_f = float(atr or 0) + except (TypeError, ValueError): + atr_f = 0.0 + if atr_f != atr_f or atr_f < 0: + atr_f = 0.0 + + stop_pct = None + atr_mult = float(settings.ATR_STOP_MULTIPLIER) + try: + from app.services.mean_reversion_strategy import resolve_swing_stop_params_v2 + stop_pct, atr_mult = resolve_swing_stop_params_v2(atr_f if atr_f > 0 else None, entry) + except Exception as _res_err: + logger.warning( + "[BUY] 스윙 손절 파라미터 해석 실패 — config STOP_LOSS_PERCENT 로 ActiveTrade 손절가 기록: %s", + _res_err, + ) + stop_pct = None + if stop_pct is None: + stop_pct = float(settings.STOP_LOSS_PERCENT) + stop_pct = -abs(float(stop_pct)) # 부호 가드 — try 밖 (양수 손절률 → 진입가 위 손절가 방지) + stop_price = entry * (1 + stop_pct / 100) + if atr_f > 0 and atr_mult and atr_mult > 0: + atr_stop = entry - float(atr_mult) * atr_f + if 0 < atr_stop < entry: + stop_price = max(stop_price, atr_stop) + stop_price = round(stop_price, 2) if 0 < stop_price < entry else None + + tp_price = None + try: + tp = abs(float(take_profit_pct)) if take_profit_pct is not None else 0.0 + except (TypeError, ValueError): + tp = 0.0 + if tp > 0 and tp == tp and tp != float("inf"): + tp_price = round(entry * (1 + tp / 100), 2) + return stop_price, tp_price + + +# ── [하드닝 R1 2026-10-09] 백그라운드 태스크 강참조 ───────────────────────────── +# asyncio 이벤트 루프는 태스크를 약참조로만 들고 있어, 반환값을 버린 create_task 는 실행 중 +# GC 로 사라질 수 있다(체결 확인·미체결 지정가 취소·진입가 보정 유실). 이 모듈의 백그라운드 +# 태스크는 전부 spawn_tracked_task_v2 로 만들고, 종료 시 drain_tracked_tasks_v2 로 기다린다. +_tracked_bg_tasks: "set[asyncio.Task]" = set() + + +def _on_tracked_task_done_v2(task: "asyncio.Task") -> None: + _tracked_bg_tasks.discard(task) + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.warning( + "[BG_TASK] 백그라운드 태스크 예외 종료 (%s): %r", task.get_name(), exc, + ) + + +def spawn_tracked_task_v2(coro, *, name: str | None = None) -> "asyncio.Task": + """강참조를 보관하는 create_task. 완료 시 자동 제거 + 예외는 warning 으로 남긴다.""" + task = asyncio.get_running_loop().create_task(coro, name=name) + _tracked_bg_tasks.add(task) + task.add_done_callback(_on_tracked_task_done_v2) + return task + + +def pending_tracked_tasks_count_v2() -> int: + return sum(1 for t in _tracked_bg_tasks if not t.done()) + + +async def drain_tracked_tasks_v2(timeout: float) -> int: + """종료 시 추적 태스크를 최대 timeout 초 기다리고 남은 것은 cancel. 취소한 개수를 반환.""" + loop = asyncio.get_running_loop() + # 다른(이미 닫힌) 루프의 태스크는 기다릴 수 없다 — 집합에서 정리만 한다. + for t in [t for t in list(_tracked_bg_tasks) if t.get_loop() is not loop]: + _tracked_bg_tasks.discard(t) + pending = [t for t in list(_tracked_bg_tasks) if not t.done()] + if not pending: + return 0 + _done, still = await asyncio.wait(pending, timeout=max(0.0, float(timeout))) + for t in still: + t.cancel() + if still: + logger.warning( + "[BG_TASK] 종료 drain %.1fs 초과 — 미완료 백그라운드 태스크 %d건 취소 " + "(체결 확인·미체결 취소 누락 가능, 수동 확인 필요)", float(timeout), len(still), + ) + await asyncio.gather(*still, return_exceptions=True) + return len(still) + + +# ── [하드닝 R1 2026-10-09] 결과 불명 BUY 정정 (§8.5-34) ───────────────────────── +# 전송 후 응답 유실(KIS_TRANSPORT_UNKNOWN 등)인 BUY 는 PENDING 으로 기록되고, 체결 확인· +# ActiveTrade 생성이 `if success:` 안에만 있어 실행되지 않았다. 실제 체결이면 ActiveTrade 없는 +# 보유 → sync 가 'synced'(-7% 고정·TP 없음)로 편입했고, 미접수면 PENDING 이 당일 재매수 차단· +# 일일 슬롯을 영구히 소진했다. 아래 resolver 가 당일 주문내역(엄격 조회: 실패를 '없음'과 구분) +# 으로 접수 여부를 확정한다. 이 상수들은 조회 타이밍이며 전략 파라미터가 아니다. +_UNKNOWN_BUY_ORDER_ID_PREFIX = "UNK-" +_UNKNOWN_BUY_RESOLVE_DELAYS_SEC = (3, 10, 30) # 누적 ≈43초 — KIS 주문내역 반영 지연 흡수 +_UNKNOWN_BUY_ORDER_TIME_SLACK_SEC = 10 # 주문 전송 시각 이전 ord_tmd 허용 오차(시계 차) + + +def _unknown_order_placeholder_id_v2(stock_code: str, order_type: str = "BUY") -> str: + """결과 불명 주문의 TradeHistory.order_id 자리표시자 — ODNO 가 없어 행을 다시 찾을 수 없었다. + + db_worker 의 order_id 갱신은 당일+최신 1행 규약이라 이 값으로 같은 행을 정정할 수 있다. + KIS ODNO(숫자)와 겹치지 않도록 접두사를 둔다. + """ + side = (order_type or "X")[:1].upper() + return f"{_UNKNOWN_BUY_ORDER_ID_PREFIX}{side}{stock_code}-{_get_kst_now():%H%M%S%f}" + + +async def _lookup_todays_orders_strict_v2(stock_code: str) -> list | None: + """당일 주문내역 — 조회 실패면 None (get_todays_orders 는 실패도 [] 라 '없음'과 구분 불가).""" + try: + result, _ = await korea_investment_api._daily_ccld_fetch_v2(stock_code=stock_code) + except Exception as e: + logger.warning( + f"[ORDER_UNKNOWN] {stock_code} 당일 주문내역 조회 예외: {type(e).__name__}: {e}" + ) + return None + if not isinstance(result, dict) or str(result.get("rt_cd")) != "0": + logger.warning( + f"[ORDER_UNKNOWN] {stock_code} 당일 주문내역 조회 실패 " + f"(rt_cd={(result or {}).get('rt_cd') if isinstance(result, dict) else None})" + ) + return None + out = result.get("output1") or [] + return out if isinstance(out, list) else [out] + + +def _match_unknown_buy_order_v2(rows: list, stock_code: str, quantity: int, sent_at) -> dict | None: + """주문내역에서 '이 BUY' 로 볼 수 있는 행 — 같은 종목·매수·수량, 전송 시각(−오차) 이후. + + 여러 건이면 전송 시각에 가장 가까운 행. 시각을 못 읽는 행은 후보에서 뺀다(오귀속 방지). + """ + try: + sent_sec = sent_at.hour * 3600 + sent_at.minute * 60 + sent_at.second + except AttributeError: + return None + best, best_gap = None, None + for row in rows or []: + try: + if not isinstance(row, dict) or row.get("pdno") != stock_code: + continue + if row.get("sll_buy_dvsn_cd") not in ("02", None, ""): + continue + if int(row.get("ord_qty", 0) or 0) != int(quantity): + continue + if not row.get("odno"): + continue + _t = str(row.get("ord_tmd") or "") + if len(_t) != 6 or not _t.isdigit(): + continue + ord_sec = int(_t[:2]) * 3600 + int(_t[2:4]) * 60 + int(_t[4:]) + gap = ord_sec - sent_sec + if gap < -_UNKNOWN_BUY_ORDER_TIME_SLACK_SEC: + continue + if best_gap is None or abs(gap) < best_gap: + best, best_gap = row, abs(gap) + except (TypeError, ValueError): + continue + return best + + +def _notify_order_unknown_v2(message: str) -> None: + try: + from app.services.telegram_notifier import telegram_notifier + spawn_tracked_task_v2(telegram_notifier.notify_system_warning("ORDER_UNKNOWN", message)) + except Exception as _tg_err: + logger.warning(f"[ORDER_UNKNOWN] 텔레그램 통지 실패: {_tg_err}") + + +async def _resolve_unknown_buy_v2(ctx: dict) -> str: + """결과 불명 BUY 를 당일 주문내역으로 확정한다. 반환: 'failed'|'cancelled'|'accepted'|'unresolved'. + + ctx 키: placeholder_id, stock_code, stock_name, quantity, order_price, sent_at(KST naive), + is_domestic, holding(bool), strategy_type, strategy_name, entry_reason, atr, take_profit + + - 미접수(마지막 조회 성공 + 일치 주문 없음) → FAILED + 사이클 매수 누적 제외 + - 접수 + 0체결·잔량 소멸 → CANCELLED + - 접수(체결 또는 미체결 대기) → COMPLETED + ActiveTrade 생성(정상 매수 경로와 같은 규약), + 체결가를 알면 체결 기준 금액으로 갱신 + - 조회 실패(마지막 시도) 또는 해외 → PENDING 유지 + error + 텔레그램(수동 확인) + TradeHistory 는 자리표시자 order_id 로 db_worker 큐 경유 갱신한다(§3.2). + """ + code = ctx["stock_code"] + qty = int(ctx["quantity"]) + pid = ctx["placeholder_id"] + strategy_type = ctx.get("strategy_type") or "swing" + tag = f"[ORDER_UNKNOWN] {code}/{ctx.get('stock_name', '')} BUY {qty}주" + from app.services.db_worker import enqueue_update_trade_history_by_order_id + + if not ctx.get("is_domestic", True): + await asyncio.sleep(_UNKNOWN_BUY_RESOLVE_DELAYS_SEC[0]) + logger.error(f"{tag} 해외 주문은 주문내역 대조 미지원 — PENDING 유지, 수동 확인 필요 (order_id={pid})") + _notify_order_unknown_v2(f"{code} 해외 BUY {qty}주 결과 불명 — 수동 확인 필요") + await _delayed_sync_portfolio(0) + return "unresolved" + + rows, match = None, None + for _delay in _UNKNOWN_BUY_RESOLVE_DELAYS_SEC: + await asyncio.sleep(_delay) + rows = await _lookup_todays_orders_strict_v2(code) + if rows is None: + continue + match = _match_unknown_buy_order_v2(rows, code, qty, ctx.get("sent_at")) + if match is not None: + break + + if match is None: + if rows is None: + # 마지막 조회가 실패 — 접수 여부를 모른다. FAILED 로 단정하면 체결분이 관리 밖에 놓인다. + logger.error( + f"{tag} 주문내역 조회 {len(_UNKNOWN_BUY_RESOLVE_DELAYS_SEC)}회 실패/미확정 — " + f"PENDING 유지, 수동 확인 필요 (order_id={pid})" + ) + _notify_order_unknown_v2(f"{code} BUY {qty}주 결과 불명·주문내역 조회 실패 — 수동 확인 필요") + await _delayed_sync_portfolio(0) + return "unresolved" + await enqueue_update_trade_history_by_order_id(pid, { + "status": "FAILED", "executed_quantity": 0, + "order_type": "BUY", "strategy_type": strategy_type, + }) + _forget_cycle_buy_v2(code, f"결과 불명 BUY 미접수 확정 (order_id={pid})") + logger.warning(f"{tag} 결과 불명 → 당일 주문내역에 없음, FAILED 로 정정 (order_id={pid})") + _notify_order_unknown_v2(f"{code} BUY {qty}주 결과 불명 → 미접수 확인(FAILED)") + await _delayed_sync_portfolio(0) + return "failed" + + odno = str(match.get("odno")) + try: + info = await korea_investment_api.get_order_execution(odno, True) or {} + except Exception as e: + logger.warning(f"{tag} 체결 조회 예외(odno={odno}): {type(e).__name__}: {e} — 접수로 처리") + info = {} + filled_qty = min(int(info.get("filled_qty", 0) or 0), qty) + filled_price = float(info.get("filled_price") or 0) + remaining = info.get("remaining_qty") + + if info.get("found") and filled_qty == 0 and (remaining == 0 or info.get("cancelled")): + await enqueue_update_trade_history_by_order_id(pid, { + "status": "CANCELLED", "executed_quantity": 0, + "order_type": "BUY", "strategy_type": strategy_type, + }) + _forget_cycle_buy_v2(code, f"결과 불명 BUY 0체결·잔량 소멸 (odno={odno})") + logger.warning(f"{tag} 접수됐으나 0체결·잔량 소멸 → CANCELLED (odno={odno})") + _notify_order_unknown_v2(f"{code} BUY {qty}주 결과 불명 → 접수·미체결 소멸(CANCELLED, odno={odno})") + await _delayed_sync_portfolio(0) + return "cancelled" + + # 접수 확정 — 정상 매수 경로(success)와 같은 규약: 주문 수량으로 COMPLETED + ActiveTrade. + # 부분체결·잔량 소멸만 체결분으로 줄인다. 잔량이 살아 있으면 정상 경로처럼 sync 가 진실. + order_price = float(ctx.get("order_price") or 0) + rec_qty = filled_qty if (0 < filled_qty < qty and remaining == 0) else qty + if filled_qty > 0 and filled_price > 0 and (filled_qty >= qty or remaining == 0): + slip = ((filled_price - order_price) / order_price * 100) if order_price > 0 else 0.0 + fields = _fill_update_fields_v2("BUY", rec_qty, filled_price, slip, None) + else: + fields = {"status": "COMPLETED", "executed_quantity": qty, "executed_at": _get_kst_now()} + fields["message"] = f"자동매수(결과 불명→접수 확인 odno={odno})" + fields["order_type"] = "BUY" + fields["strategy_type"] = strategy_type + await enqueue_update_trade_history_by_order_id(pid, fields) + + entry_price = filled_price if (filled_price > 0 and filled_qty > 0) else order_price + _at_stop_price, _at_tp_price = _swing_active_trade_levels_v2( + entry_price, ctx.get("atr"), ctx.get("take_profit", 5.0), + ) + try: + from app.services.db_worker import enqueue_active_trade_upsert + await enqueue_active_trade_upsert({ + "stock_code": code, + "stock_name": ctx.get("stock_name"), + "accumulate_entry": bool(ctx.get("holding")), + "quantity": rec_qty, + "entry_price": entry_price, + "strategy_name": ctx.get("strategy_name") or strategy_type, + "stop_loss": _at_stop_price, + "take_profit": _at_tp_price, + "entry_reason": ctx.get("entry_reason"), + "highest_since_entry": entry_price, + }) + except Exception as _at_err: + logger.warning(f"{tag} ActiveTrade 생성 실패: {_at_err}") + logger.warning( + f"{tag} 결과 불명 → 접수 확인(odno={odno}, 체결 {filled_qty}/{qty}주, 잔량={remaining}) " + f"— COMPLETED·ActiveTrade 등록" + ) + _notify_order_unknown_v2( + f"{code} BUY {qty}주 결과 불명 → 접수 확인(odno={odno}, 체결 {filled_qty}주) — 포지션 관리 등록" + ) + await _delayed_sync_portfolio(0) + return "accepted" + + +async def _verify_and_sync_execution_v3( + order_id: str, + stock_code: str, + stock_name: str, + ordered_quantity: int, + order_price: float, + order_type: str, + is_domestic: bool, + exchange_code: str, + db_trade_id: int, + delay: int = 3, + cancel_unfilled_limit: bool = False, + rollback_active_trade: dict | None = None, + order_org_no: str | None = None, + sell_pnl_basis: dict | None = None, + fill_entry_strategy_name: str | None = None, +) -> None: + """[2026-10-08] v2 + 체결 기준 금액·손익 갱신. + + sell_pnl_basis: {"entry_price", "market", "is_domestic", "strategy_type", "net_rate"} — + SELL 체결 확인 시 순손익·비용·수익률을 체결가로 재계산(_fill_update_fields_v2). + strategy_type 은 order_id 중복 행 좁히기용 식별 키로도 쓴다. + fill_entry_strategy_name: BUY 체결 확인 시 이 전략명의 ActiveTrade.entry_price 를 + 체결가 기준으로 보정(db_worker entry_fill_adjust). None 이면 보정하지 않는다. + + [H-1 5회차] cancel_unfilled_limit=True: 폴링 종료 시 국내 SELL 잔량이 살아 있으면 취소. rollback_active_trade: 주문 전 ActiveTrade 스냅샷(전략 필터 포함). 0체결 취소 확정 시 그대로 upsert 해 remaining_pct·quantity·highest_since_entry 를 원복한다(행을 닫지 않는 부분 익절 전용). @@ -1017,6 +1822,9 @@ async def _verify_and_sync_execution_v2( await _delayed_sync_portfolio(0) return + # [하드닝 R7] 자동매수가 등록한 ActiveTrade 원복 정보(불성립·부분체결 확정 시 사용) + _buy_rb = _pop_buy_entry_rollback_v2(order_id, stock_code) if order_type == "BUY" else None + try: # KIS 체결 내역 조회 (inquire-daily-ccld, TR: TTTC0081R→폴백 TTTC8001R / 모의 V*) # [실전 안정화] 최대 5회 폴링 (3초 간격, 최대 15초) — 체결 지연 대응 @@ -1030,7 +1838,8 @@ async def _verify_and_sync_execution_v2( for poll in range(max_polls): exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic) filled_qty = int(exec_result.get("filled_qty", 0) or 0) - filled_price = float(exec_result.get("filled_price", order_price) or order_price) + # [2026-10-08] 평균체결가가 없으면 0 — 주문가로 대체하지 않는다(아래 경고 분기) + filled_price = float(exec_result.get("filled_price") or 0) remaining_qty = exec_result.get("remaining_qty") if filled_qty >= ordered_quantity: @@ -1060,6 +1869,28 @@ async def _verify_and_sync_execution_v2( # [L-4 4회차] 체결 0 + 잔량 소멸 = 매수 불성립 → CANCELLED. COMPLETED 로 남으면 # 일일 스윙 슬롯·already_ordered_today·Kelly 통계가 허수 매수로 오염된다. await _mark_trade_cancelled_v2(order_id, db_trade_id, filled_qty=0) + # [하드닝 R7] 주문 시점에 만든 스윙 ActiveTrade 도 닫는다 — sync 의 유령 정리에 + # 맡기면 그 사이 청산 판정이 없는 포지션을 보고, 장부(BUY만 있는 고아)가 남는다. + await _rollback_buy_active_trade_v2(order_id, _buy_rb, 0) + elif order_type == "BUY": + # 잔량이 살아 있거나(지정가 대기) 확인 불가 — 매수 미체결 주문은 취소하지 않는다 + # (4회차 정책). TradeHistory 의 COMPLETED·전량은 **잠정치**이고 확정은 + # swing/fill_sweep 의 후속 조회에 맡긴다. 끝내 체결되지 않으면 장부가 거짓이 되므로 + # 식별 가능한 warning 을 남긴다. + logger.warning( + f"[BUY_LEDGER] {stock_code} BUY {ordered_quantity}주 체결 0 · 잔량 " + f"{remaining_qty if remaining_qty is not None else '미상'} — TradeHistory COMPLETED 는 " + f"잠정(미체결 소멸 시 CANCELLED 강등 필요, order_id={order_id})" + ) + + # [2026-10-08] 체결수량>0 인데 평균체결가를 못 받으면 주문가로 대체하지 않는다 + # (#432/#433 처럼 호가단위상 불가능한 주문가가 체결가로 저장되던 경로). + if filled_qty > 0 and float(exec_result.get("filled_price") or 0) <= 0: + logger.warning( + f"[VERIFY_EXEC] {stock_code} {order_type} 체결 {filled_qty}주 확인됐으나 평균체결가 없음 " + f"— 체결가·금액 갱신 생략(주문가 대체 안 함, order_id={order_id})" + ) + filled_price = 0.0 # [H-1 4회차] 국내 SELL 이 폴링 종료 시 잔량이 KIS 에 살아 있으면(지정가 미체결) 취소한다. # 남겨 두면 수량이 잠겨 swing/exit_service 의 시장가 손절이 종일 '주문가능수량 부족'으로 @@ -1092,11 +1923,14 @@ async def _verify_and_sync_execution_v2( # ── 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 ───────────────── slippage_pct = 0.0 if filled_qty > 0 and order_price > 0 and filled_price > 0: + # 저장값은 side 무관 원시값(규약: slippage_cost_bp_v2 주석). 로그에는 side 보정 비용도 남긴다. slippage_pct = ((filled_price - order_price) / order_price) * 100 + _slip_cost_bp = slippage_cost_bp_v2(order_type, slippage_pct) logger.info( f"[SLIPPAGE] {stock_code} {order_type} " f"주문가={order_price:.2f} 체결가={filled_price:.2f} " - f"슬리피지={slippage_pct:+.3f}%" + f"슬리피지={slippage_pct:+.3f}%(원시) " + f"비용={'n/a' if _slip_cost_bp is None else f'{_slip_cost_bp:+.1f}bp'}(불리=+)" ) partial = 0 < filled_qty < ordered_quantity @@ -1112,23 +1946,48 @@ async def _verify_and_sync_execution_v2( # ── DB 업데이트 (CLAUDE.md 3.2: db_worker 큐 경유, 직접 세션 금지) ─── if filled_qty > 0 and (not partial or remainder_dead): - update_fields = { - "executed_quantity": filled_qty, - "executed_price": Decimal(str(filled_price)), - # PARTIAL 금지 — 위 docstring ② (가드·집계 누락) - "status": "COMPLETED", - "executed_at": _get_kst_now(), - "slippage": Decimal(str(round(slippage_pct, 4))), - } + # PARTIAL 금지 — 위 docstring ② (가드·집계 누락). 금액·손익은 체결 기준(_fill_update_fields_v2) + update_fields = _fill_update_fields_v2( + order_type, filled_qty, filled_price, slippage_pct, sell_pnl_basis, + ) + # order_id 중복 행 좁히기용 식별 키(db_worker 가 setattr 하지 않음) + update_fields["order_type"] = order_type + if (sell_pnl_basis or {}).get("strategy_type"): + update_fields["strategy_type"] = sell_pnl_basis["strategy_type"] if db_trade_id: - # 기존 경로: trade_id로 조회 (레거시 호출 호환) + # 기존 경로: trade_id로 조회 (레거시 호출 호환) — id 경로는 모든 키를 setattr 하므로 + # order_id 경로 전용 식별 키는 뺀다(행의 전략 구분을 덮어쓰지 않도록). from app.services.db_worker import enqueue_update_trade_history - await enqueue_update_trade_history(db_trade_id, update_fields) + await enqueue_update_trade_history(db_trade_id, { + k: v for k, v in update_fields.items() if k not in ("order_type", "strategy_type") + }) else: # 신규 경로: order_id로 조회 (db_worker 큐 경유 INSERT 시 trade_id 미확보) from app.services.db_worker import enqueue_update_trade_history_by_order_id await enqueue_update_trade_history_by_order_id(order_id, update_fields) + # [하드닝 R7] 부분체결·잔량 소멸 BUY → ActiveTrade 수량을 체결수량으로. + # 아래 체결가 보정보다 먼저 큐잉해야 보정 분모(행 수량)가 체결수량이 된다. + if order_type == "BUY" and remainder_dead: + await _rollback_buy_active_trade_v2(order_id, _buy_rb, filled_qty) + + # ── BUY 체결가로 스윙 ActiveTrade 진입가 보정 (db_worker 큐) ────────── + # 진입 시 upsert 는 주문가(+0.5% 조정가)로 기록된다. 스윙 손익·손절이 이 값을 + # 진입가로 쓰므로(swing/position_manager.resolve_swing_entry_price_v2) 체결가로 맞춘다. + if order_type == "BUY" and fill_entry_strategy_name and filled_price > 0: + from app.services.db_worker import enqueue_active_trade_upsert + # swing/fill_sweep 가 같은 주문을 다시 보정하지 않도록 표시(비멱등 — 이중 보정 방지) + note_entry_fill_adjusted_v2(order_id) + await enqueue_active_trade_upsert({ + "stock_code": stock_code, + "strategy_name": fill_entry_strategy_name, + "entry_fill_adjust": { + "order_price": float(order_price), + "filled_price": float(filled_price), + "filled_qty": int(filled_qty), + }, + }) + # ── 부분 체결 + 잔량 소멸 → 자동 재주문 금지, 수동 확인 요청 (docstring ③) ── if remainder_dead: remain = ordered_quantity - filled_qty @@ -1178,6 +2037,46 @@ async def _reorder_unfilled( logger.error(f"[REORDER_ERROR] {stock_code} {order_type}: {type(e).__name__}: {e}") +def _recommendation_trailing_high_v2(stock: dict, current_price: float) -> float: + """[하드닝 R2 2026-10-09] 추천 경로 check_exit_signal 의 트레일링 고점. + + 진입 이후 고점(ActiveTrade.highest_since_entry → stock["highest_since_entry"])과 현재가 중 + 큰 값. 진입 이후 고점을 모르면 현재가만 쓴다(트레일링 = 현재가 − k×ATR 이라 발동하지 않음). + 당일 고가(stock["high_price"])는 진입 전 체결이 섞이므로 절대 쓰지 않는다 — + swing/exit_service 의 trailing_high 정의(highest_since_entry, 현재가로 상향)와 같다. + """ + try: + cur = float(current_price or 0) + except (TypeError, ValueError): + cur = 0.0 + hse_raw = stock.get("highest_since_entry") + try: + hse = float(hse_raw) if hse_raw is not None else 0.0 + except (TypeError, ValueError): + logger.warning(f"[EXIT_SIGNAL] highest_since_entry 파싱 실패({hse_raw!r}) — 현재가로 대체") + hse = 0.0 + if hse != hse or hse == float("inf"): # NaN/inf 방어 + hse = 0.0 + return max(hse, cur) + + +def _swing_entry_price_for_stock_v2(stock: dict, holding, stock_code: str) -> float: + """[2026-10-08] 스윙 손익·손절 판정 진입가. + + Portfolio.average_price 는 같은 종목 스윙+DT 혼합 평단일 수 있다(§3.5). 스윙 소유 + ActiveTrade.entry_price(execute_auto_trade 가 stock["_swing_entry_price"] 로 주입)를 + 우선하고, 없으면 Portfolio 평단으로 폴백한다(swing/position_manager 의 단일 규칙). + DT 태그 종목은 기존대로 Portfolio 평단. + """ + _pa = float(getattr(holding, "average_price", 0) or 0) if holding is not None else 0.0 + from app.services.db_worker import DT_STRATEGY_NAMES + if (stock.get("strategy_type") or "swing") in DT_STRATEGY_NAMES: + return _pa + from app.services.swing.position_manager import resolve_swing_entry_price_v2 + price, _basis = resolve_swing_entry_price_v2(stock.get("_swing_entry_price"), _pa, stock_code) + return price + + def _existing_position_qty_v2(holding, stock_code: str) -> int: """해당 종목의 기존 보유 수량 (종목당 누적 상한 산정용). @@ -1540,15 +2439,9 @@ async def _execute_buy( # 넘지 않도록 상한. 스윙 투자액 = 주식 평가액 − 데이트레이딩 포지션 투자액. # (스윙이 자본 91%를 점유해 DT 예산이 남지 않던 문제 → 50:50) _swing_cap_ratio = float(getattr(settings, "SWING_MAX_CAPITAL_RATIO", 1.0) or 1.0) + _swing_room: float | None = None if 0 < _swing_cap_ratio < 1 and total_assets > 0: - try: - from app.services.daytrading.position_manager import day_trading_state as _dts - _dt_amount = sum( - float(p.get("entry_price", 0) or 0) * int(p.get("quantity", 0) or 0) - for p in _dts.positions.values() - ) - except Exception: - _dt_amount = 0.0 + _dt_amount = _dt_invested_amount_v2(stock_code) _swing_invested = max(0.0, total_assets - available_cash - _dt_amount) _swing_room = total_assets * _swing_cap_ratio - _swing_invested if invest_amount > _swing_room: @@ -1592,6 +2485,11 @@ async def _execute_buy( ) else: invest_amount = min(invest_amount, available_cash) + # [R6-1 2026-10-10] 패턴메모리 증액(최대 1.5배) 뒤 현금 유보·스윙 70% 상한 재적용. + # 리스크 가드 — try 밖에서 무조건(§4, §8.1-10). + invest_amount = _reapply_capital_guards_v2( + invest_amount, available_cash, min_cash_reserve, _swing_room, + ) # ── 7.6. 종목당 **누적** 상한 (CLAUDE.md §5.1) ───────────────────────── # [2026-10-01] 위 클램프는 '주문 1건' 기준이라, 같은 종목이 매일 추천되면 @@ -1704,6 +2602,7 @@ async def _execute_buy( # 재시도: exponential backoff (1→2→4초, 최대 3회) try: order_price = _adjust_order_price(current_price, "BUY", is_domestic) # +0.5% 체결률 향상 + _unknown_sent_at = _get_kst_now() # 결과 불명 시 주문내역 대조 기준 시각 async with _get_order_semaphore(stock_code): order_result = await _place_order_with_retry( is_domestic=is_domestic, @@ -1796,10 +2695,16 @@ async def _execute_buy( _note_cycle_buy_v2(stock_code, stock_name, quantity, float(order_price)) if stock_code in _recent_cycle_buys: _recent_cycle_buys[stock_code]["strategy_type"] = stock.get("strategy_type", "swing") - asyncio.create_task(_delayed_sync_portfolio(3)) + # [하드닝 R1 2026-10-09] 잔고 동기화는 resolver 가 확정 후 실행한다 — 먼저 돌리면 + # 체결분이 ActiveTrade 등록 전에 'synced' 로 시딩돼 이중 행이 생길 수 있다. # TradeHistory DB 기록 (db_worker 큐 경유 — CLAUDE.md 3.2 준수) kis_order_id = build_kis_order_id(order_result) + # [하드닝 R1 2026-10-09] 결과 불명은 ODNO 가 없어 행을 다시 찾을 수 없다 → 자리표시자 + _unknown_placeholder_id = None + if _outcome_unknown and not kis_order_id: + _unknown_placeholder_id = _unknown_order_placeholder_id_v2(stock_code, "BUY") + kis_order_id = _unknown_placeholder_id from app.services.db_worker import enqueue_trade_history await enqueue_trade_history({ "rule_id": None, # JSON config rule은 trading_rules 테이블에 없으므로 FK 위반 방지 @@ -1808,7 +2713,11 @@ async def _execute_buy( "order_type": "BUY", "order_price": Decimal(str(order_price)), "order_quantity": quantity, - "executed_price": Decimal(str(order_price)) if success else None, + # [2026-10-08] 체결가는 체결 확인(_verify_and_sync_execution_v3)이 채운다 — 그 전엔 NULL. + # 주문가(+0.5% 조정가)를 체결가로 넣으면 확인 실패 시 호가단위상 불가능한 값 + # (#433 현대차 337,177 / #432 풍산 68,139)이 체결가로 영구히 남았다. + # total_amount 는 확인 전까지 주문가 기준 추정치, 확인 시 체결가×체결수량으로 덮어쓴다. + "executed_price": None, "executed_quantity": quantity if success else 0, "total_amount": total_amount, "status": status, @@ -1823,20 +2732,51 @@ async def _execute_buy( "executed_at": _get_kst_now() if success else None, }) + if _outcome_unknown: + # [하드닝 R1 2026-10-09 §8.5-34] 결과 불명 BUY 확정 태스크 (FAILED/CANCELLED/접수 등록) + if _unknown_placeholder_id: + spawn_tracked_task_v2(_resolve_unknown_buy_v2({ + "placeholder_id": _unknown_placeholder_id, + "stock_code": stock_code, + "stock_name": stock_name, + "quantity": quantity, + "order_price": float(order_price), + "sent_at": _unknown_sent_at, + "is_domestic": is_domestic, + "holding": bool(holding), + "strategy_type": stock.get("strategy_type", "swing"), + "strategy_name": stock.get("strategy_name") or stock.get("strategy_type", "swing"), + "entry_reason": entry_reason, + "atr": stock.get("atr"), + "take_profit": stock.get("take_profit", 5.0), + })) + else: + # 불명 응답에 ODNO 가 있다(드묾) — 접수된 것이므로 정상 경로처럼 체결 확인에 맡긴다. + spawn_tracked_task_v2(_delayed_sync_portfolio(3)) + if success: + # [하드닝 R1 2026-10-09] ActiveTrade.stop_loss/take_profit 은 절대가 규약 + # (sync 시딩·DT restore 와 동일). 손절가는 swing/exit_service 와 같은 실효값. + _at_stop_price, _at_tp_price = _swing_active_trade_levels_v2( + order_price, stock.get("atr"), stock.get("take_profit", 5.0), + ) # [C2] ActiveTrade 생성 — 분할매도/트레일링 스탑 추적용 try: from app.services.db_worker import enqueue_active_trade_upsert await enqueue_active_trade_upsert({ "stock_code": stock_code, "stock_name": stock_name, + # [2026-10-08] 추가매수면 기존 행에 수량 가산·진입가 가중평균(덮어쓰기 금지). + # 같은 전략명 active 행이 없으면 db_worker 가 신규 생성한다(키는 무시). + "accumulate_entry": bool(holding), "quantity": quantity, "entry_price": order_price, # 듀얼 전략(MOMENTUM_BREAKOUT/PULLBACK_BUY)은 strategy_name 으로 전달돼 # 청산 분기를 결정한다. 없으면 strategy_type 폴백. "strategy_name": stock.get("strategy_name") or stock.get("strategy_type", "swing"), - "stop_loss": stock.get("stop_loss", -float(settings.STOP_LOSS_PERCENT)), - "take_profit": stock.get("take_profit", 5.0), + # [하드닝 R1] 퍼센트(-1.50/3.00) 저장 금지 — 절대가(실효 손절가)로 통일 + "stop_loss": _at_stop_price, + "take_profit": _at_tp_price, "entry_reason": entry_reason, "highest_since_entry": order_price, }) @@ -1867,7 +2807,7 @@ async def _execute_buy( # Telegram 매수 체결 알림 try: from app.services.telegram_notifier import telegram_notifier - asyncio.create_task(telegram_notifier.notify_trade_executed( + spawn_tracked_task_v2(telegram_notifier.notify_trade_executed( order_type="BUY", stock_code=stock_code, stock_name=stock_name, @@ -1876,9 +2816,15 @@ async def _execute_buy( )) except Exception as _swallowed: logger.warning("[BUY] 텔레그램 체결 알림 실패: %s", _swallowed) + # [하드닝 R7] 0체결 소멸·부분체결 확정 시 위 ActiveTrade 를 실제 체결 기준으로 원복 + register_buy_entry_rollback_v2( + kis_order_id, stock_code, + stock.get("strategy_name") or stock.get("strategy_type", "swing"), + quantity, bool(holding), + ) # 3초 후 KIS 체결 확인 → 슬리피지 기록 + 부분체결 재주문 (백그라운드) - asyncio.create_task( - _verify_and_sync_execution( + spawn_tracked_task_v2( + _verify_and_sync_execution_v3( order_id=kis_order_id, stock_code=stock_code, stock_name=stock_name, @@ -1889,6 +2835,11 @@ async def _execute_buy( exchange_code=exchange_code, db_trade_id=0, # db_worker 큐 경유로 trade_id 미확보, order_id로 조회 delay=3, + # [2026-10-08] 체결가로 ActiveTrade 진입가 보정 (위 upsert 와 같은 전략명) + fill_entry_strategy_name=( + stock.get("strategy_name") or stock.get("strategy_type", "swing") + ), + sell_pnl_basis={"strategy_type": stock.get("strategy_type", "swing")}, ) ) # [H-1] 기존 `elif is_pending:` 자동취소 분기 제거 — rt_cd≠"0" 은 거부라 @@ -1971,6 +2922,199 @@ def _sell_ownership_block_reason_v2(holding, stock: dict) -> str | None: return None if _caller_tag == _holding_type else "owned_by_other_strategy" +# ── [하드닝 R1 2026-10-09] 추천 경로 매도 ↔ swing/exit_service 공용 가드 ────────────── +# 스윙 보유분을 매도하는 엔진이 둘이다(swing/exit_service 60초 주기, 추천 경로 _execute_sell). +# exit_service 는 종목별 최근 청산 시각(_recent_exits, EXIT_COOLDOWN_SEC)과 매도 직전 KIS +# 실보유 상한(_presell_kis_holding_v2)으로 큐 지연 중 이중 분할매도·과다매도를 막는데, +# _execute_sell 은 둘 다 쓰지 않아 큐 적체 시 같은 단계를 한 번 더 낼 수 있었다. +# → 두 경로가 같은 _recent_exits 딕셔너리와 같은 KIS 상한 함수를 쓴다(정본은 exit_service). + +# ── [하드닝 R1] 가격 필터 ETF/리츠 예외 판정 ───────────────────────────────────── +# 기존: 종목명에 키워드가 '어디든' 들어 있으면 예외 → '인프라'로 시작하는 일반주, 영문명에 +# SOL/ACE/ETF 가 섞인 종목(NETFLIX ⊃ ETF)이 5,000원 이하여도 §5.1 가격 필터를 우회했다. +# - 운용사 브랜드: 종목명 '접두사' + 뒤가 영문자가 아닐 때만 ("KODEX 200", "SOL 미국배당") +# - ETF/ETN/REIT: 독립 단어(영문자 경계)일 때만 +# - 리츠/인프라: 종목명 '접미사'일 때만 ("SK리츠", "맥쿼리인프라") +_PRICE_EXEMPT_ETF_BRANDS = ("KODEX", "TIGER", "KBSTAR", "ARIRANG", "SOL", "HANARO", "ACE") +_PRICE_EXEMPT_WORD_RE = re.compile(r"(? bool: + """가격 필터(MIN_PRICE_*) 예외 대상(ETF/ETN/리츠/인프라펀드) 종목명 판정. 이름 없으면 False.""" + name = str(stock_name or "").strip().upper() + if not name: + return False + for brand in _PRICE_EXEMPT_ETF_BRANDS: + if name.startswith(brand): + nxt = name[len(brand):len(brand) + 1] + if not ("A" <= nxt <= "Z"): + return True + if _PRICE_EXEMPT_WORD_RE.search(name): + return True + return name.endswith(_PRICE_EXEMPT_SUFFIXES) + + +def _swing_exit_cooldown_block_v2(stock_code: str) -> str | None: + """공용 스윙 청산 쿨다운 판정. 차단이면 사유 문자열, 허용이면 None. + + swing/exit_service._recent_exits 를 그대로 참조한다(한쪽에만 기록하면 다시 이중화된다). + 참조 자체가 실패하면 fail-closed('cooldown_check_failed') — 추천 경로 매도를 보류해도 + exit_service 가 손절·익절을 계속 판정하므로 청산이 사라지지는 않는다(§8.1-11). + """ + try: + from app.services.swing.exit_service import _recent_exits, EXIT_COOLDOWN_SEC + _last = _recent_exits.get(stock_code) + _cool = float(EXIT_COOLDOWN_SEC) + except Exception as e: + logger.warning( + f"[SELL] {stock_code}: 공용 청산 쿨다운 조회 실패 — 추천 경로 매도 보류 " + f"({type(e).__name__}: {e})" + ) + return "cooldown_check_failed" + if _last is None: + return None + _elapsed = time.time() - float(_last) + if _elapsed < _cool: + logger.info( + f"[SELL] {stock_code}: 직전 스윙 청산 {int(_elapsed)}초 전 — 공용 쿨다운" + f"({int(_cool)}초) 중 추천 경로 매도 스킵" + ) + return "recent_swing_exit_cooldown" + return None + + +def _mark_swing_exit_v2(stock_code: str) -> None: + """추천 경로 매도(접수 또는 결과 불명)를 공용 _recent_exits 에 기록 — exit_service 도 쿨다운.""" + try: + from app.services.swing.exit_service import _recent_exits + _recent_exits[stock_code] = time.time() + except Exception as e: + logger.warning( + f"[SELL] {stock_code}: 공용 청산 시각 기록 실패 — exit_service 쿨다운 미적용 가능 " + f"({type(e).__name__}: {e})" + ) + + +async def _recommendation_sell_kis_cap_v2(stock_code: str, dt_qty: int) -> "tuple[int, int] | None": + """추천 경로 국내 매도 직전 KIS 실보유 상한. (매도 상한, 스윙 실보유) 또는 None(조회 실패). + + 매도 상한 = max(0, min(주문가능, 보유) − DT 인메모리) — swing/exit_service 와 같은 식. + 스윙 실보유 = max(0, 보유 − DT) — 전량 청산 판정 기준(대기 중 매도 주문분은 보유에 남는다). + 조회 실패면 None → 호출부는 매도를 보류한다(과다매도보다 청산 지연이 낫다, exit_service 와 동일). + """ + try: + from app.services.swing.exit_service import _presell_kis_holding_v2 + except Exception as e: + logger.warning(f"[SELL] {stock_code}: KIS 상한 함수 import 실패 — 매도 보류 ({type(e).__name__}: {e})") + return None + snap = await _presell_kis_holding_v2(stock_code) + if snap is None: + return None + _hldg, _sellable = int(snap[0]), int(snap[1]) + _dt = max(0, int(dt_qty or 0)) + return max(0, min(_sellable, _hldg) - _dt), max(0, _hldg - _dt) + + +async def _recommendation_prior_position_net_v2( + db: AsyncSession, stock_code: str, market, +) -> "float | None": + """[하드닝 R1 2026-10-09] 추천 경로 전량 청산 — 이번 레그 이전에 실현된 포지션 순손익 합(원). + + swing/exit_service 와 **같은 정의**(_prior_position_realized_net_v2)를 쓰기 위해 비-DT active + ActiveTrade 행을 SELECT 해 그대로 위임한다. 주문 전에 호출해야 한다(이번 레그 TradeHistory 가 + 큐로 먼저 커밋되면 이중 합산). 행이 없거나 조회·import 실패면 None → 이번 레그 순손익만으로 판정. + SELECT 전용(commit·add 없음). + """ + try: + from app.models.models import ActiveTrade as _AT + from app.services.db_worker import not_dt_strategy_clause + from app.services.swing.exit_service import _prior_position_realized_net_v2 + _row = (await db.execute( + select(_AT) + .where( + _AT.stock_code == stock_code, + _AT.status == "active", + not_dt_strategy_clause(_AT.strategy_name), + ) + .order_by(_AT.id.desc()) + .limit(1) + )).scalars().first() + if _row is None: + return None + return await _prior_position_realized_net_v2(db, stock_code, _row, market) + except Exception as e: + logger.warning( + f"[SELL] {stock_code}: 포지션 누적 실현손익 조회 실패 — 이번 레그 순손익만으로 승패 판정 " + f"({type(e).__name__}: {e})" + ) + return None + + +def _recommendation_position_is_loss_v2(prior_realized_net, leg_net) -> bool: + """추천 경로 전량 청산 승패 — swing/exit_service._position_is_loss_v2 와 동일 정의(순손익, §5.1). + + 포지션 누적 순손익(이전 레그 합 + 이번 레그 순손익) < 0 이면 패. 왕복 비용 이내의 소폭 이익도 패. + """ + try: + from app.services.swing.exit_service import _position_is_loss_v2 + except Exception as e: + logger.warning(f"[SELL] _position_is_loss_v2 import 실패 — 동일 식 인라인 사용 ({type(e).__name__}: {e})") + total = float(leg_net) + (float(prior_realized_net) if prior_realized_net is not None else 0.0) + return total < 0 + return _position_is_loss_v2(prior_realized_net, leg_net) + + +def _non_name_target_for_sell_v2(stock: dict) -> tuple: + """전략명으로 매칭할 수 없을 때의 ActiveTrade 대상 — (strategy_name, filter_not_dt). + + DT 로 태깅된 보유분은 DT 전략명(기존 동작), 그 외는 비-DT 최신 행을 직접 겨냥한다. + """ + from app.services.db_worker import DT_STRATEGY_NAMES + _st = stock.get("strategy_type") or "swing" + if _st in DT_STRATEGY_NAMES: + return _st, False + return None, True + + +async def _resolve_sell_active_trade_target_v2(db, stock: dict, stock_code: str) -> tuple: + """추천 경로 매도의 ActiveTrade 대상 결정 — (strategy_name|None, filter_not_dt). + + filter_not_dt=True 면 payload 에 strategy_name_not=DT_STRATEGY_NAMES 를 써서 비-DT + 최신 행을 겨냥한다(전략명 NULL·'synced' 등 이름을 모르는 행 포함). + [하드닝 R8] 역추적 SELECT 예외 시 예전에는 로그 없이 'swing' 이름으로 폴백해, + 실제 행이 'synced' 면 close/upsert 매칭이 0건이 되어 유령 active 행이 남고 + remaining_pct 미갱신으로 분할 단계가 재발동할 수 있었다 → warning + 비-DT 필터. + """ + _name = stock.get("strategy_name") + if _name: + return _name, False + try: + from app.models.models import ActiveTrade as _AT + from app.services.db_worker import not_dt_strategy_clause + _row = (await db.execute( + select(_AT) + .where( + _AT.stock_code == stock_code, + _AT.status == "active", + not_dt_strategy_clause(_AT.strategy_name), + ) + .order_by(_AT.id.desc()) + .limit(1) + )).scalars().first() + _name = _row.strategy_name if _row else None + except Exception as e: + logger.warning( + f"[SELL] {stock_code}: ActiveTrade 전략명 역추적 실패 → 비-DT 최신 행 필터로 대상 지정 " + f"({type(e).__name__}: {e})" + ) + return _non_name_target_for_sell_v2(stock) + if _name: + return _name, False + # 비-DT 행이 없거나(=닫을 대상 없음) 전략명이 NULL 인 경우. + return _non_name_target_for_sell_v2(stock) + + async def _execute_sell( stock: dict, db: AsyncSession, @@ -2032,6 +3176,13 @@ async def _execute_sell( logger.warning(_msg + " — 다른 전략 엔진 소유") return {"action": "SKIP", "reason": _ownership_block, "stock": stock_name} + # ── [하드닝 R1] swing/exit_service 와 공용 청산 쿨다운 (try 밖 가드, §4) ────────── + # 큐 적체로 remaining_pct·Portfolio 가 아직 옛 값이면 exit_service 가 막 낸 분할매도와 + # 같은 단계를 추천 경로가 다시 낼 수 있다 → 같은 _recent_exits 로 차단. + _cooldown_block = _swing_exit_cooldown_block_v2(stock_code) + if _cooldown_block is not None: + return {"action": "SKIP", "reason": _cooldown_block, "stock": stock_name} + # [CLAUDE.md 5.2] 분할매도: sell_pct에 따라 매도 수량 결정 # [2026-09-23] Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+데이트레이딩 # 수량이 합산돼 있다. 행 전체를 대상으로 잡으면 다른 전략 보유분까지 매도된다 @@ -2096,6 +3247,41 @@ async def _execute_sell( # 분기를 그대로 타면 close_active_trade 가 호출되지 않아 quantity=0 유령 active # 행이 남고, loss_tracker.record_trade_result_safe() 도 스킵돼 §5.1 3연패 밴 # 카운트에서 이 청산이 빠진다 → 실제 체결 기준으로 전량 청산 처리한다. + # ── [하드닝 R1] 국내 매도 직전 KIS 실보유 상한 (swing/exit_service 와 같은 식, try 밖 §4) ── + # Portfolio 수량(sync 지연)·remaining_pct(큐 지연)만으로 정하면 exit_service 가 막 판 + # 물량이나 DT 물량까지 다시 팔 수 있다. 상한 = min(주문가능, 보유) − DT 인메모리. + # 조회 실패·DT 수량 확인 불가면 매도 보류(청산 지연 < 과다매도), 상한 0 이면 주문 안 함. + if is_domestic: + if _strategy_tag != "daytrading": + _dt_for_cap = _dt_inmemory_qty_v2(stock_code) + if _dt_for_cap is None: + logger.warning(f"[SELL] {stock_code}: DT 수량 확인 불가 — 추천 경로 매도 보류") + return {"action": "SKIP", "reason": "dt_qty_unknown", "stock": stock_name} + else: + _dt_for_cap = 0 + _kis_cap = await _recommendation_sell_kis_cap_v2(stock_code, _dt_for_cap) + if _kis_cap is None: + return {"action": "SKIP", "reason": "kis_holding_check_failed", "stock": stock_name} + _cap_qty, _kis_own_qty = _kis_cap + if _cap_qty <= 0: + logger.warning( + f"[SELL] {stock_code}: KIS 기준 매도 가능 0주 (스윙 실보유 {_kis_own_qty}, " + f"DT {_dt_for_cap}) — 추천 경로 매도 안 함" + ) + return {"action": "SKIP", "reason": "kis_sellable_zero", "stock": stock_name} + if _kis_own_qty < total_qty: + logger.warning( + f"[SELL] {stock_code}: 장부 보유 {total_qty}주 > KIS 실보유 {_kis_own_qty}주 — KIS 기준 적용" + ) + total_qty = _kis_own_qty + if quantity > _cap_qty: + logger.warning( + f"[SELL] {stock_code}: 매도 수량 {quantity}주 > KIS 매도 상한 {_cap_qty}주 — 상한 적용" + ) + quantity = _cap_qty + if quantity > total_qty: + quantity = total_qty + _full_exit = sell_pct >= 1.0 or (total_qty > 0 and quantity >= total_qty) if _full_exit and sell_pct < 1.0: logger.info( @@ -2103,8 +3289,12 @@ async def _execute_sell( f"({total_qty}주)과 동일 → 전량 청산으로 처리 (ActiveTrade 종료 + 손실추적)" ) - _avg = float(holding.average_price or 0) - profit_loss_rate = calculate_profit_loss_rate(current_price, _avg, holding.profit_loss_rate) + _avg = _swing_entry_price_for_stock_v2(stock, holding, stock_code) + if abs(_avg - float(holding.average_price or 0)) >= 0.01: + # 혼합 평단 대신 스윙 진입가로 판정할 때는 KIS 손익률(혼합 평단 기준)을 쓰지 않는다 + profit_loss_rate = calculate_profit_loss_rate(current_price, _avg, None) + else: + profit_loss_rate = calculate_profit_loss_rate(current_price, _avg, holding.profit_loss_rate) # [CLAUDE.md 5.2] 기본 손절 -6% (안정적 종목), 익절은 ATR 기반 2R take_profit = stock.get("take_profit", 5.0) @@ -2117,7 +3307,8 @@ async def _execute_sell( base_stop_loss=base_stop_loss ) # [2026-09-27] atr_stops["stop_loss"] 는 _execute_sell 내에서 사용되지 않아 제거 - # (실제 손절 판정은 execute_auto_trade의 ATR 강제 손절/check_exit_signal에서 수행) + # (실제 스윙 손절 판정은 swing/exit_service 단일 경로 — execute_auto_trade 의 + # check_exit_signal/ATR 강제 손절 블록은 추천 dict 에 atr 이 없어 운영상 휴면이다) trailing_stop_price = atr_stops["trailing_stop_price"] exit_reason_detail = atr_stops["exit_reason_detail"] or f"손익률 {profit_loss_rate:.2f}%" @@ -2140,6 +3331,14 @@ async def _execute_sell( if duplicate_check.scalars().first(): return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name} + # [하드닝 R1 2026-10-09] 전량 청산 승패용 이전 레그 순손익 합 — 주문 전에 구한다 + # (이번 레그 TradeHistory 가 큐로 먼저 커밋되면 이중 합산). swing/exit_service 와 같은 정의. + _prior_realized_net = None + if _full_exit: + _prior_realized_net = await _recommendation_prior_position_net_v2( + db, stock_code, stock.get("market", "KOSPI") or "KOSPI", + ) + # ── 2. 주문 실행 (종목별 세마포어 + 재시도) ────────────────────────── # [H-1 4회차 2026-10-02] current_price 는 추천 경로에서 StockAnalysis 배치 값(최대 30분 # 낡음)이다. 그 ×0.995 지정가는 하락 중 미체결로 남아 수량을 잠가 swing/exit_service 의 @@ -2173,8 +3372,9 @@ async def _execute_sell( # 체결 확인·슬리피지 기준가 (시장가면 직전 기준가) _verify_ref_price = float(order_price) if order_price is not None else float(_ref_price) + # 확인 전 추정치(배치 현재가 기준) — 체결 확인 시 체결가×체결수량·순손익으로 덮어쓴다 total_amount = Decimal(str(current_price)) * quantity - gross_profit_loss = (current_price - float(holding.average_price or current_price)) * quantity + gross_profit_loss = (current_price - (_avg or current_price)) * quantity # [#4 수수료/세금 반영] net PnL 계산 _market_type = stock.get("market", "KOSPI") or "KOSPI" trade_costs = calculate_trade_costs( @@ -2190,7 +3390,11 @@ async def _execute_sell( _outcome_unknown = (not success) and _is_order_outcome_unknown_v2(order_result) status = "COMPLETED" if success else ("PENDING" if _outcome_unknown else "FAILED") if _outcome_unknown: - asyncio.create_task(_delayed_sync_portfolio(3)) + spawn_tracked_task_v2(_delayed_sync_portfolio(3)) + # [하드닝 R1] 접수·결과 불명 매도는 공용 청산 시각에 기록 → exit_service 도 쿨다운을 적용해 + # 큐 반영 전 같은 단계 재매도를 막는다(거부=FAILED 는 기록하지 않아 즉시 재시도 허용). + if success or _outcome_unknown: + _mark_swing_exit_v2(stock_code) # [H-1 5회차] 미체결 자동 취소 = 국내 지정가 부분 익절 + 자기 주문번호일 때만. # 시장가(전량·손절)는 절대 취소하지 않는다. 조회로 매칭된 주문(_matched_by_lookup)은 # 자기 주문이라는 보장이 없어 취소 대상에서 뺀다(L-5). @@ -2208,9 +3412,10 @@ async def _execute_sell( "stock_code": stock_code, "stock_name": stock_name, "order_type": "SELL", - "order_price": Decimal(str(current_price)), + # [2026-10-08] order_price = 실제 지정가(시장가면 주문 직전 기준가). 체결가는 확인 후 기록. + "order_price": Decimal(str(_verify_ref_price)), "order_quantity": quantity, - "executed_price": Decimal(str(current_price)) if success else None, + "executed_price": None, "executed_quantity": quantity if success else 0, "total_amount": total_amount, "status": status, @@ -2234,7 +3439,9 @@ async def _execute_sell( if _full_exit: try: from app.services.loss_tracker import loss_tracker - is_loss = profit_loss_rate < 0 + # [하드닝 R1 2026-10-09] gross 수익률(profit_loss_rate) 이 아니라 비용 차감 순손익 + + # 포지션 누적(이전 분할 레그) 기준 — swing/exit_service 와 동일 정의(§5.1). + is_loss = _recommendation_position_is_loss_v2(_prior_realized_net, profit_loss) await loss_tracker.record_trade_result_safe( stock_code=stock_code, is_loss=is_loss, @@ -2247,41 +3454,12 @@ async def _execute_sell( try: # 실제 행의 strategy_name 을 사용한다. strategy_type("swing")으로 닫으면 # 듀얼 전략 행(MOMENTUM_BREAKOUT/PULLBACK_BUY)과 매칭되지 않아 유령 행이 남는다. - _at_name = stock.get("strategy_name") - # [§8.5 항목3 2026-09-27] 역추적 쿼리에 DT 제외 조건이 없어, 같은 종목에 - # DT 행이 병존하면 스윙 매도가 DT 행의 strategy_name 을 집어 DT - # ActiveTrade 를 닫아버렸다 → not_dt_strategy_clause 로 통일. - # 전략명이 NULL 인 비-DT 행(legacy/동기화 유입)은 이름으로 매칭할 수 - # 없으므로 payload 에 strategy_name_not 필터를 써서 그 행을 직접 겨냥한다 - # ('swing' 으로 폴백하면 NULL 행과 매칭되지 않아 유령 active 행이 남는다). - _at_filter_not_dt = False - if not _at_name: - try: - from app.models.models import ActiveTrade as _AT - from app.services.db_worker import not_dt_strategy_clause - _at_row = (await db.execute( - select(_AT) - .where( - _AT.stock_code == stock_code, - _AT.status == "active", - not_dt_strategy_clause(_AT.strategy_name), - ) - .order_by(_AT.id.desc()) - .limit(1) - )).scalars().first() - _at_name = _at_row.strategy_name if _at_row else None - if not _at_name: - # 비-DT 행이 없거나(=닫을 대상 없음) 전략명이 NULL 인 경우. - # DT 로 태깅된 보유분은 기존 동작(=DT 행 종료)을 유지한다. - from app.services.db_worker import DT_STRATEGY_NAMES - _st = stock.get("strategy_type") or "swing" - if _st in DT_STRATEGY_NAMES: - _at_name = _st - else: - _at_filter_not_dt = True - except Exception: - _at_name = None - _at_filter_not_dt = False + # [§8.5 항목3 2026-09-27] 역추적은 not_dt_strategy_clause 로 DT 행 제외. + # 전략명 NULL 비-DT 행·역추적 실패(하드닝 R8)는 strategy_name_not 필터로 + # 비-DT 최신 행을 직접 겨냥한다('swing' 폴백은 매칭 0건 → 유령 active 행). + _at_name, _at_filter_not_dt = await _resolve_sell_active_trade_target_v2( + db, stock, stock_code + ) _at_name = _at_name or stock.get("strategy_type", "swing") if _full_exit: # [M-2] 실제 체결이 보유 전량이면 active 행을 닫는다 @@ -2397,7 +3575,7 @@ async def _execute_sell( # Telegram 매도 체결 알림 try: from app.services.telegram_notifier import telegram_notifier - asyncio.create_task(telegram_notifier.notify_trade_executed( + spawn_tracked_task_v2(telegram_notifier.notify_trade_executed( order_type="SELL", stock_code=stock_code, stock_name=stock_name, @@ -2409,8 +3587,8 @@ async def _execute_sell( except Exception as _swallowed: logger.warning("[SELL] 텔레그램 체결 알림 실패: %s", _swallowed) # 3초 후 KIS 체결 확인 (슬리피지 기록 포함) - asyncio.create_task( - _verify_and_sync_execution_v2( + spawn_tracked_task_v2( + _verify_and_sync_execution_v3( order_id=kis_order_id, stock_code=stock_code, stock_name=stock_name, @@ -2424,6 +3602,14 @@ async def _execute_sell( cancel_unfilled_limit=_cancel_unfilled_limit, rollback_active_trade=_at_rollback, order_org_no=_extract_org_no_v2(order_result), + # [2026-10-08] 체결가 기준 순손익 재계산 (기록 시와 같은 진입가·시장 구분) + sell_pnl_basis={ + "entry_price": _avg, + "market": _market_type, + "is_domestic": is_domestic, + "strategy_type": stock.get("strategy_type", "swing"), + "net_rate": False, + }, ) ) # [H-1] 기존 `elif is_pending:` 자동취소 분기 제거 (BUY 와 동일 사유). @@ -2745,6 +3931,23 @@ async def _delayed_sync_portfolio(delay_seconds: int = 3): logger.error(f"[PORTFOLIO_SYNC] 딜레이 동기화 실패: {type(e).__name__}: {e}") +# [하드닝 R5 2026-10-09] market_analyzer 가 RSI 매수 신호에 거래량 배수 < 1.0 이면 +# " (거래량부족-차단)" 을 붙인다(market_analyzer.py 의 analyze_rsi 분기). 이 마커는 +# 종목 단위 사실(당일 거래량 < 20일 평균)이므로 rule 강제매수·ML BUY·비규칙 신호 어느 경로든 +# 신규 매수를 막는다. 백테스트 check_buy_signal 의 공통 선행 필터(current_volume < avg → +# 거부, CLAUDE.md §6.2)와 화면 buy_allowed(recommendations._block_reasons_v2)와도 일치한다. +_VOLUME_FLOOR_BLOCK_MARKER = "거래량부족-차단" + + +def _volume_floor_block_reason_v2(signals) -> "str | None": + """신호 목록에 거래량 하한 차단 마커가 있으면 해당 신호 문자열, 없으면 None.""" + for sig in (signals or []): + text = str(sig) + if _VOLUME_FLOOR_BLOCK_MARKER in text: + return text + return None + + def _ml_gate_score_v2(rule_score: float, ml_score: float) -> float: """ML 매수/HOLD 게이트 비교용 점수 산출 (0~100 스케일 기준). @@ -2778,15 +3981,31 @@ def _ml_gate_score_v2(rule_score: float, ml_score: float) -> float: return (rule_score / rule_max) * 100.0 +def _fill_or_created_at_v2(): + """TradeHistory 의 '체결 시각' 표현 — executed_at, 없으면 created_at. + + [하드닝 R1 2026-10-09] swing/exit_service(손절 포함)·DT·MDD 의 SELL 행은 COMPLETED 인데 + executed_at 이 NULL 이다(운영 14일: 스윙 SELL 29/29, DT SELL 33/33). executed_at 만 보면 + 이 행들이 '당일 매도'·최근 거래 정렬에서 빠진다. + """ + return func.coalesce(TradeHistory.executed_at, TradeHistory.created_at) + + def _today_sell_conditions_v2(stock_code: str, cutoff_time) -> list: - """당일 SELL 판정 조건 (COMPLETED/PARTIAL 은 executed_at, PENDING 은 created_at 기준).""" + """당일 SELL 판정 조건. + + COMPLETED/PARTIAL 은 coalesce(executed_at, created_at), PENDING 은 created_at 기준. + [하드닝 R1 2026-10-09] executed_at 단독 비교는 executed_at 을 기록하지 않는 경로 + (swing/exit_service 손절·DT·MDD)의 SELL 을 놓쳐, 손절 직후 같은 종목 당일 재매수와 + 부분매도 반복이 허용됐다(인메모리 _recent_exits 30초만 방어). + """ return [ TradeHistory.stock_code == stock_code, TradeHistory.order_type == "SELL", or_( and_( TradeHistory.status.in_(["COMPLETED", "PARTIAL"]), - TradeHistory.executed_at >= cutoff_time, + _fill_or_created_at_v2() >= cutoff_time, ), and_( TradeHistory.status == "PENDING", @@ -2862,11 +4081,8 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): # [M-6 2026-10-02] 판정만 여기서 하고, 적용은 보유 조회 뒤 `_guard` 로 한다. # 기존엔 즉시 SKIP 반환해 5,000원 이하로 떨어진 보유 종목의 손절·청산 판정이 # 막혔다(§8.1-11 청산 우선 위반). - _ETF_KEYWORDS = ( - "ETF", "KODEX", "TIGER", "KBSTAR", "ARIRANG", "SOL", - "HANARO", "ACE", "리츠", "REIT", "인프라", - ) - _is_etf = stock_name and any(kw in (stock_name or "").upper() for kw in _ETF_KEYWORDS) + # [하드닝 R1] 부분문자열 판정('인프라웨어'·'NETFLIX'⊃ETF 등 일반주 통과) → 위치 한정 판정 + _is_etf = _is_price_filter_exempt_name_v2(stock_name) if is_domestic: _is_penny = current_price <= settings.MIN_PRICE_KRW and not _is_etf # CLAUDE.md 5.4: "초과" else: @@ -3030,6 +4246,9 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): _active_trade = _at_result.scalars().first() if _active_trade is not None and _active_trade.remaining_pct is not None: stock["remaining_pct"] = float(_active_trade.remaining_pct) + # [2026-10-08] 스윙 진입가(혼합 평단 회피) — _swing_entry_price_for_stock_v2 가 쓴다 + if _active_trade is not None and getattr(_active_trade, "entry_price", None): + stock["_swing_entry_price"] = float(_active_trade.entry_price) # [Low 3회차] 부분매도 시 고점을 낮추지 않도록 기존 고점을 함께 전달 _hse = getattr(_active_trade, "highest_since_entry", None) if _active_trade is not None else None if _hse is not None and stock.get("highest_since_entry") is None: @@ -3146,6 +4365,18 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): should_buy = False logger.info(f"[TRADE] {stock_code}: ML HOLD(conf≥0.6) → 매수 차단") + # [하드닝 R5] 거래량 하한 차단 — has_rule 강제매수(위)·ML BUY 가 '(거래량부족-차단)' + # 을 덮어써 9/28 이후 스윙 BUY 25건 중 8건이 거래량 미달로 체결됐다. + # 신규 매수만 끈다(조기 return 없음) → 보유 종목 청산 판정은 그대로 진행(§8.1-11). + # try 밖에 둔다(§4 가드 규칙). + _vol_block_sig = _volume_floor_block_reason_v2(signals) + if _vol_block_sig is not None and should_buy: + should_buy = False + logger.info( + f"[TRADE] {stock_code}/{stock_name}: 거래량 하한 미달 → 매수 차단 " + f"(has_rule={has_rule}, ml_signal={ml_signal_trade}, signal='{_vol_block_sig}')" + ) + if should_buy and not has_rule: # 비규칙 종목: 강한 신호 OR (신호 2개 이상 AND 점수 4점 이상) 필수 has_strong_signal = any(k in text_signals_filtered for k in STRONG_BUY_KEYWORDS) @@ -3157,10 +4388,19 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): # [2026-03-15] 기존에 텍스트 신호 매칭에만 의존하던 매도 판단을 # mean_reversion_strategy.check_exit_signal()로 보완. # RSI/BB/ATR 기반 부분매도(50%)/전량매도/트레일링스탑 규칙 적용. + # ⚠️ [하드닝 R2 2026-10-09] 운영 추천 경로에서는 **휴면(dormant)** 이다. + # recommendations._stock_analysis_to_dict 가 만드는 dict 에는 atr/bb/closes 키가 + # 없어 atr=0 → 아래 두 블록(check_exit_signal·ATR 강제 손절)이 실행되지 않는다. + # **스윙 청산의 실효 경로는 swing/exit_service 단일 경로**이며, 이 블록을 + # 스윙 손절의 2차 방어로 간주하지 말 것. 호출부가 atr(+rsi/bb 또는 closes)을 + # 실어 보낼 때만 활성화된다(예: 미배선 박스권 dict). 그때 트레일링 고점은 + # 당일 고가(high_price)가 아니라 진입 이후 고점(ActiveTrade.highest_since_entry) + # 을 쓴다 — _recommendation_trailing_high_v2. _exit_signal = None _exit_sell_pct = 1.0 if holding and not should_sell: - avg_price = float(holding.average_price or 0) + # [2026-10-08] Portfolio 평단은 스윙+DT 혼합일 수 있다 → 스윙 ActiveTrade 진입가 우선 + avg_price = _swing_entry_price_for_stock_v2(stock, holding, stock_code) atr = float(stock.get("atr", 0) or 0) if avg_price > 0: @@ -3180,7 +4420,8 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): # remaining_pct: 현재 보유 비율 (1차 익절 전 = 1.0, 후 = 0.5 등) # [§8.5 항목4] None = '추적 소스 없음' 을 명시 상태로 분리. remaining_pct, _remaining_tracked = _resolve_remaining_pct_v2(stock, holding) - trailing_high = float(stock.get("high_price") or current_price) + # [하드닝 R2] 당일 고가(high_price) 금지 — 진입 이후 고점만 쓴다. + trailing_high = _recommendation_trailing_high_v2(stock, current_price) lowest_since = float(stock.get("lowest_since_entry") or avg_price) _exit_signal = check_exit_signal( diff --git a/backend/app/services/trade_helpers.py b/backend/app/services/trade_helpers.py index 32c9c10..78006d4 100644 --- a/backend/app/services/trade_helpers.py +++ b/backend/app/services/trade_helpers.py @@ -39,6 +39,18 @@ async def check_opening_range_volatility() -> bool: return is_opening_range_period() +def _bars_oldest_first(rows, date_key: str) -> list: + """KIS 일봉 output2 를 과거→최근 순으로 정렬한다. + + KIS 는 최신 봉이 앞(newest-first)이고 30행을 준다. 날짜 키가 모두 있으면 + 날짜 오름차순으로, 없으면 KIS 기본 순서(최신 우선)로 보고 뒤집는다. + """ + rows = [r for r in (rows or []) if isinstance(r, dict)] + if rows and all(r.get(date_key) for r in rows): + return sorted(rows, key=lambda r: str(r.get(date_key))) + return list(reversed(rows)) + + async def check_surge_prevention( stock_code: str, is_domestic: bool, @@ -69,13 +81,25 @@ async def check_surge_prevention( ) chart_data = chart_resp.get("output2", []) - if len(chart_data) >= 4: + chart_data = _bars_oldest_first( + chart_data, "stck_bsop_date" if is_domestic else "xymd" + ) + # 오늘 봉(장중 미완성)이 포함되면 [-4:] 첫 봉 = 3거래일 전, + # 아직 없으면 [-3:] 첫 봉 = 3거래일 전(오늘 기준). + _win = 4 + if is_domestic and chart_data: + _today = now_kst().strftime("%Y%m%d") + if str(chart_data[-1].get("stck_bsop_date")) != _today: + _win = 3 + chart_data = chart_data[-_win:] + + if len(chart_data) >= _win: closes = ( - [float(item.get("stck_clpr", 0)) for item in chart_data[-4:]] + [float(item.get("stck_clpr", 0)) for item in chart_data] if is_domestic - else [float(item.get("clos", 0)) for item in chart_data[-4:]] + else [float(item.get("clos", 0)) for item in chart_data] ) - if len(closes) >= 4: + if len(closes) >= _win: price_3days_ago = closes[0] recent_gain = ( ((current_price - price_3days_ago) / price_3days_ago) * 100 @@ -83,14 +107,14 @@ async def check_surge_prevention( ) try: highs = ( - [float(item.get("stck_hgpr", 0)) for item in chart_data[-4:]] + [float(item.get("stck_hgpr", 0)) for item in chart_data] if is_domestic - else [float(item.get("high", 0)) for item in chart_data[-4:]] + else [float(item.get("high", 0)) for item in chart_data] ) lows = ( - [float(item.get("stck_lwpr", 0)) for item in chart_data[-4:]] + [float(item.get("stck_lwpr", 0)) for item in chart_data] if is_domestic - else [float(item.get("low", 0)) for item in chart_data[-4:]] + else [float(item.get("low", 0)) for item in chart_data] ) daily_ranges = [ abs(highs[i] - lows[i]) for i in range(len(closes)) @@ -156,16 +180,29 @@ async def check_trend_filter( if ma20 is None or ma60 is None: if is_domestic: chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D") - closes_local = [float(it.get("stck_clpr", 0)) for it in (chart_resp.get("output") or [])] + _rows = _bars_oldest_first( + chart_resp.get("output2") or chart_resp.get("output") or [], "stck_bsop_date" + ) + closes_local = [float(it.get("stck_clpr", 0)) for it in _rows] else: chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D") - closes_local = [float(it.get("clos", 0)) for it in (chart_resp.get("output2") or [])] + _rows = _bars_oldest_first(chart_resp.get("output2") or [], "xymd") + closes_local = [float(it.get("clos", 0)) for it in _rows] if len(closes_local) >= max(TREND_MA_SHORT, TREND_MA_LONG): ma20 = _ma(closes_local, TREND_MA_SHORT) ma60 = _ma(closes_local, TREND_MA_LONG) ema20 = _ema(closes_local, TREND_MA_SHORT) ema60 = _ema(closes_local, TREND_MA_LONG) + elif not (ma20 and ma60): + # 데이터 부족(KIS 폴백 30봉)은 하락추세 증거가 아니다. 산출 불가 시 통과시키되 + # 로그를 남긴다(전 비-평균회귀 매수 전면 차단 방지). + warn_throttled( + logger, f"trade_helpers:trend_nodata:{stock_code}", + f"[TREND_FILTER] {stock_code} MA{TREND_MA_LONG} 산출 불가" + f"(봉 {len(closes_local)}<{max(TREND_MA_SHORT, TREND_MA_LONG)}) -> 필터 통과(데이터 부족)", + ) + return {"skip": False} ma_ok = bool( ma20 and ma60 diff --git a/backend/app/services/us_market_scheduler.py b/backend/app/services/us_market_scheduler.py index da1b775..06df203 100644 --- a/backend/app/services/us_market_scheduler.py +++ b/backend/app/services/us_market_scheduler.py @@ -34,6 +34,7 @@ from app.services.us_stock_strategy import ( from app.services.korea_investment_api import korea_investment_api from app.services.exchange_detector import detect_exchange from app.services.mdd_manager import mdd_manager +from app.services.loss_tracker import loss_tracker from app.services.db_worker import ( enqueue_trade_history, enqueue_active_trade_upsert, @@ -436,6 +437,27 @@ async def _monitor_us_exits(exchange_rate: float = settings.US_DEFAULT_FX_RATE) # ── 단일 매매 사이클 ──────────────────────────────────────────────────────────── +async def _us_entry_blocked_by_ban_v2(ticker: str) -> Optional[str]: + """연속손실 밴(§5.1) 확인. 차단 사유 문자열 또는 None(진입 가능). + + 판정 불가(예외)는 fail-closed — try 에는 조회만 두고 판정은 밖에서 한다. + 섹터 3종목·상관 0.7 필터는 국내 데이터(섹터맵/KR 일봉) 전용이라 미국 종목에 + 적용할 수 없어 미구현 (US_TRADING_ENABLED 켜기 전 별도 작업). + """ + banned = True + err: Optional[str] = None + try: + banned = await loss_tracker.is_banned_safe(ticker) + except Exception as e: + err = f"{type(e).__name__}: {e}" + if err is not None: + logger.warning("[US Buy] %s 밴 판정 불가 — 신규 진입 차단(fail-closed): %s", ticker, err) + return "ban_check_unavailable" + if banned: + return "loss_ban" + return None + + async def us_trade_cycle() -> dict: """단일 매매 사이클: 스캔 → 신호 체크 → 주문 실행 → 포지션 청산 모니터링 @@ -512,6 +534,11 @@ async def us_trade_cycle() -> dict: current_price_usd = float(sig["price"]) exchange = detect_exchange(ticker) + _ban_reason = await _us_entry_blocked_by_ban_v2(ticker) + if _ban_reason is not None: + logger.info("[US Buy] %s — %s, 건너뜀", ticker, _ban_reason) + continue + try: # 7-1. 포지션 사이징 buy_budget_krw = calculate_us_position_size( diff --git a/backend/app/services/walk_forward.py b/backend/app/services/walk_forward.py index cc76c2b..db63b24 100644 --- a/backend/app/services/walk_forward.py +++ b/backend/app/services/walk_forward.py @@ -12,8 +12,20 @@ Out-of-Sample 구간에서 검증하여 과적합 여부를 탐지합니다. 5. 전체 윈도우 종합 결과 반환 의존성: - - backtest_engine.py (BacktestEngine, BacktestConfig) + - kr_backtest_engine.py (KrBacktestEngine + MeanReversionBacktest, 실전 스윙 청산 로직) - optuna (requirements.txt에 추가 필요) + +[2026-10 하드닝 R2] 최적화기는 이제 kr_backtest_engine(_v2 경로)을 돌린다. +이전 판은 장난감 엔진(backtest_engine.BacktestEngine: 고정 손절 + 2R 고정 익절)을 +돌렸고 BB_PERIOD·ATR_STOP_MULTIPLIER 는 아무 효과가 없었다. 따라서 +**이 변경 이전에 생성된 walk-forward 산출물(recommended_params 포함)은 인용 금지**이다. + +알려진 한계(추정이 아니라 코드 확인 사항): + - kr_backtest_engine 의 진입 RSI 임계는 params 로 주입되지 않는다 + (mean_reversion_strategy.check_buy_signal 이 adaptive → strategy_cfg → settings 를 읽음). + 그래서 RSI 진입 임계는 탐색 공간에서 제외했다. + - 엔진의 수수료·세금은 settings(KR_*) 고정이라 WalkForwardConfig.commission_rate/tax_rate 는 + _v2 경로에서 쓰이지 않는다. """ import asyncio @@ -99,6 +111,9 @@ class WalkForwardResult: # 최종 추천 파라미터 (OOS Sharpe 가중평균) recommended_params: Dict[str, Any] + # 산출 엔진 표식 (구 장난감 엔진 산출물과 구분) + engine: str = "" + # ── 유틸리티 ───────────────────────────────────────────────────────────────── @@ -202,7 +217,7 @@ class WalkForwardOptimizer: params: Dict[str, Any], wf_config: WalkForwardConfig, ) -> Dict[str, float]: - """단일 백테스트를 실행하고 핵심 지표를 반환합니다. + """[DEPRECATED — 인용 금지] 장난감 엔진(backtest_engine) 실행. `_run_single_backtest_v2` 사용. Returns: {sharpe_ratio, total_return_pct, mdd_pct, win_rate, total_trades} @@ -234,6 +249,85 @@ class WalkForwardOptimizer: "total_trades": result.total_trades, } + def _lazy_import_v2(self): + """실전 스윙 청산 로직을 담은 KrBacktestEngine 을 로드한다. + + 구 `_lazy_import` 는 존재하지 않는 이름(BacktestEngine)을 import 해 항상 ImportError 로 + 장난감 엔진에 폴백했다. v2 는 폴백 없이 ImportError 를 그대로 올린다(조용한 폴백 금지). + """ + if getattr(self, "_kr_engine_cls", None) is None: + from app.services.kr_backtest_engine import KrBacktestEngine + self._kr_engine_cls = KrBacktestEngine + + async def _run_single_backtest_v2( + self, + stock_codes: List[str], + start_date: date, + end_date: date, + params: Dict[str, Any], + wf_config: WalkForwardConfig, + ) -> Dict[str, float]: + """kr_backtest_engine(실전 청산 모델)으로 단일 백테스트를 실행한다. + + 반환 단위는 구 `_run_single_backtest` 와 동일하게 맞춘다(mdd/수익률/승률 = 비율, 0.1=10%). + kr 엔진은 % 단위(10.0=10%)이므로 /100 변환한다. + """ + self._lazy_import_v2() + from app.core.config import settings + + engine_params = dict(params) + # 사이징은 하드 상한(MAX_POSITION_SIZE)·보유수 상한(MAX_HOLDINGS_COUNT)을 넘기지 않는다. + engine_params["position_size_pct"] = min( + float(wf_config.position_size), float(settings.MAX_POSITION_SIZE)) + engine_params["max_positions"] = min( + int(wf_config.max_positions), int(settings.MAX_HOLDINGS_COUNT)) + + engine = self._kr_engine_cls() + result = await engine.run( + stock_codes=stock_codes, + start_date=start_date, + end_date=end_date, + strategy_name="mean_reversion", + params=engine_params, + initial_capital=wf_config.initial_capital, + ) + m = result.metrics + return { + "sharpe_ratio": m.sharpe_ratio, + "total_return_pct": m.total_return_pct / 100.0, + "mdd_pct": m.max_drawdown_pct / 100.0, + "win_rate": m.win_rate / 100.0, + "total_trades": m.total_trades, + } + + def _build_params_from_trial_v2(self, trial) -> Dict[str, Any]: + """kr_backtest_engine 이 실제로 소비하는 키만 탐색한다. + + 탐색 범위(구 버전과 동일 값): + - bb_period: 15 ~ 25 + - atr_stop_multiplier: 1.5 ~ 3.5 + - stop_loss_pct: 4 ~ 10 (양수 규약, 엔진이 -abs() 부호 가드 적용) + RSI 진입 임계는 엔진이 params 로 받지 않으므로 제외. 익절은 엔진의 실전 분할익절 모델. + """ + bb_period = trial.suggest_int("bb_period", 15, 25) + atr_multiplier = trial.suggest_float("atr_multiplier", 1.5, 3.5, step=0.1) + stop_loss_pct = trial.suggest_float("stop_loss_pct", 4.0, 10.0, step=0.5) + return { + "bb_period": bb_period, + "atr_stop_multiplier": atr_multiplier, + "stop_loss_pct": abs(stop_loss_pct), + } + + @staticmethod + def _default_params_v2() -> Dict[str, Any]: + """모든 trial 실패 시 폴백 — 임의 상수가 아니라 현행 settings 값.""" + from app.core.config import settings + return { + "bb_period": int(settings.BOLLINGER_PERIOD), + "atr_stop_multiplier": float(settings.ATR_STOP_MULTIPLIER), + "stop_loss_pct": abs(float(settings.STOP_LOSS_PERCENT)), + } + def _build_params_from_trial(self, trial) -> Dict[str, Any]: """Optuna trial에서 전략 파라미터를 생성합니다. @@ -289,10 +383,10 @@ class WalkForwardOptimizer: best_params_holder = [None] async def objective_async(trial) -> float: - params = self._build_params_from_trial(trial) + params = self._build_params_from_trial_v2(trial) try: - metrics = await self._run_single_backtest( + metrics = await self._run_single_backtest_v2( stock_codes=wf_config.stock_codes, start_date=is_start, end_date=is_end, @@ -353,18 +447,11 @@ class WalkForwardOptimizer: best_params = best_params_holder[0] if best_params is None: # 모든 trial이 실패한 경우 기본 파라미터 사용 - best_params = { - "MEAN_REVERSION_RSI_THRESHOLD": 30, - "BB_PERIOD": 20, - "ATR_STOP_MULTIPLIER": 2.5, - "MEAN_REVERSION_STOP_LOSS_PCT": -6.0, - "MEAN_REVERSION_TAKE_PROFIT_PCT": 12.0, - "RSI_OVERBOUGHT_LEVEL": 70.0, - } + best_params = self._default_params_v2() logger.warning(f"[윈도우 {window_idx}] 모든 trial 실패 -> 기본 파라미터 사용") # ── IS 최종 성과 (best_params) ── - is_metrics = await self._run_single_backtest( + is_metrics = await self._run_single_backtest_v2( stock_codes=wf_config.stock_codes, start_date=is_start, end_date=is_end, @@ -373,7 +460,7 @@ class WalkForwardOptimizer: ) # ── OOS 검증 ── - oos_metrics = await self._run_single_backtest( + oos_metrics = await self._run_single_backtest_v2( stock_codes=wf_config.stock_codes, start_date=oos_start, end_date=oos_end, @@ -429,12 +516,9 @@ class WalkForwardOptimizer: 모든 OOS Sharpe가 음수이면 동일 가중치(산술 평균)를 사용합니다. """ param_keys = [ - "MEAN_REVERSION_RSI_THRESHOLD", - "BB_PERIOD", - "ATR_STOP_MULTIPLIER", - "MEAN_REVERSION_STOP_LOSS_PCT", - "MEAN_REVERSION_TAKE_PROFIT_PCT", - "RSI_OVERBOUGHT_LEVEL", + "bb_period", + "atr_stop_multiplier", + "stop_loss_pct", ] # 가중치 계산 (OOS Sharpe, 최소 0) @@ -459,7 +543,7 @@ class WalkForwardOptimizer: raw_val = weighted_sum / total_weight # 정수 파라미터 반올림 - if key in ("MEAN_REVERSION_RSI_THRESHOLD", "BB_PERIOD"): + if key == "bb_period": recommended[key] = int(round(raw_val)) else: recommended[key] = round(raw_val, 2) @@ -552,6 +636,7 @@ class WalkForwardOptimizer: avg_sharpe_degradation=round(float(np.mean(degradations)), 4), overfit_ratio=round(overfit_count / len(window_results), 4), recommended_params=recommended, + engine="kr_backtest_engine_v2", ) logger.info( diff --git a/backend/app/services/websocket_manager.py b/backend/app/services/websocket_manager.py index 577f12a..7e348b2 100644 --- a/backend/app/services/websocket_manager.py +++ b/backend/app/services/websocket_manager.py @@ -7,24 +7,35 @@ WebSocket 연결 관리 모듈 - 싱글톤 websocket_manager 인스턴스 제공 (main.py 에서 import) """ from typing import Dict, Set +import asyncio from fastapi import WebSocket from app.core.json_helpers import _dumps import logging logger = logging.getLogger(__name__) +# [L-6] 실시간 시세 전송 1건당 클라이언트별 타임아웃(초). 초과 = 정체된 클라이언트 → 연결 해제. +_MARKET_UPDATE_SEND_TIMEOUT = 5.0 + class WebSocketManager: def __init__(self): self.active_connections: Dict[str, WebSocket] = {} # 클라이언트별 구독 종목 세트 self._subscriptions: Dict[str, Set[str]] = {} + # 클라이언트별 "선택 종목"(차트를 보고 있는 종목) — KIS 등록 우선순위·호가 구독 대상(H-4) + self._selected: Dict[str, Set[str]] = {} # ------------------------------------------------------------------ # 연결 관리 # ------------------------------------------------------------------ async def connect(self, websocket: WebSocket, client_id: str): - """클라이언트 연결. + """클라이언트 연결 (서브프로토콜 없음 — connect_v2 위임).""" + await self.connect_v2(websocket, client_id) + + async def connect_v2(self, websocket: WebSocket, client_id: str, subprotocol: str | None = None): + """클라이언트 연결. subprotocol 이 있으면 accept(subprotocol=...) 로 응답 + (브라우저는 요청한 서브프로토콜이 응답에 없으면 연결을 끊는다). 동일 client_id로 재접속 시 active_connections를 교체. 이전 핸들러는 websocket_endpoint의 루프에서 교체 감지 후 자연 종료. @@ -33,7 +44,7 @@ class WebSocketManager: if client_id in self.active_connections: logger.info(f"Client {client_id}: 기존 연결 교체 (이전 핸들러 자연 종료 대기)") - await websocket.accept() + await websocket.accept(subprotocol=subprotocol) self.active_connections[client_id] = websocket self._subscriptions.setdefault(client_id, set()) logger.info(f"Client {client_id} connected. Total: {len(self.active_connections)}") @@ -42,6 +53,7 @@ class WebSocketManager: """클라이언트 연결 해제""" self.active_connections.pop(client_id, None) self._subscriptions.pop(client_id, None) + self._selected.pop(client_id, None) logger.info(f"Client {client_id} disconnected. Total: {len(self.active_connections)}") # ------------------------------------------------------------------ @@ -64,6 +76,18 @@ class WebSocketManager: self._subscriptions[client_id] = set(stock_codes) logger.debug(f"[sub=] {client_id}: 구독 목록 재설정 → {sorted(stock_codes)}") + def set_selected_codes_v2(self, client_id: str, stock_codes: list[str]): + """클라이언트의 선택 종목(차트 표시 종목)을 전체 교체.""" + self._selected[client_id] = {str(c) for c in stock_codes if c} + + def get_selected_codes_v2(self) -> Set[str]: + """활성 클라이언트의 선택 종목 합집합 (해당 클라이언트가 실제 구독 중인 종목만).""" + result: Set[str] = set() + for client_id in self.active_connections: + subs = self._subscriptions.get(client_id, set()) + result.update(c for c in self._selected.get(client_id, set()) if c in subs) + return result + def get_subscriptions(self, client_id: str) -> list[str]: """클라이언트의 현재 구독 목록 반환.""" return sorted(self._subscriptions.get(client_id, set())) @@ -108,34 +132,45 @@ class WebSocketManager: async def send_market_update(self, stock_code: str, data: dict): """시장 데이터를 해당 종목을 구독한 클라이언트에게만 전송. - 구독자가 없으면 전체 브로드캐스트(기존 동작 유지). + + [L-5] 구독자가 없으면 아무에게도 보내지 않는다(과거: 전체 브로드캐스트 폴백 — + 구독하지 않은 종목 틱이 모든 화면에 쏟아졌다). + [L-6] 클라이언트별 전송을 병렬 + 타임아웃으로 — 느린 클라이언트 1개가 KIS 분배를 막지 않게. """ - message = _dumps({ - "type": "market_update", - "stock_code": stock_code, - "data": data, - }) - # 해당 종목 구독 클라이언트 필터링 targets = [ cid for cid, codes in self._subscriptions.items() if stock_code in codes and cid in self.active_connections ] if not targets: - # 구독자 없음 — 전체 브로드캐스트(fallback) - await self.broadcast(message) return - disconnected = [] - for cid in targets: + message = _dumps({ + "type": "market_update", + "stock_code": stock_code, + "data": data, + }) + + async def _send_one(cid: str): ws = self.active_connections.get(cid) if not ws: - continue + return None try: - await ws.send_text(message) + await asyncio.wait_for(ws.send_text(message), timeout=_MARKET_UPDATE_SEND_TIMEOUT) + return None + except asyncio.TimeoutError: + logger.warning(f"send_market_update 타임아웃({_MARKET_UPDATE_SEND_TIMEOUT}s) → {cid} 연결 해제") + return (cid, ws) except Exception as e: logger.error(f"send_market_update error → {cid}: {e}") - disconnected.append(cid) - for cid in disconnected: - self.disconnect(cid) + return (cid, ws) + + results = await asyncio.gather(*(_send_one(cid) for cid in targets)) + for item in results: + if not item: + continue + cid, ws = item + # 전송 중 같은 client_id 로 재접속했으면 새 연결은 건드리지 않는다 + if self.active_connections.get(cid) is ws: + self.disconnect(cid) async def send_trade_notification(self, trade_data: dict): """거래 알림 전송""" diff --git a/backend/backfill_swing_entry_fill.py b/backend/backfill_swing_entry_fill.py new file mode 100644 index 0000000..fec30a3 --- /dev/null +++ b/backend/backfill_swing_entry_fill.py @@ -0,0 +1,52 @@ +"""[하드닝 R3 2026-10-09] 기존 스윙 ActiveTrade.entry_price 1회성 백필 (주문가 → 체결가). + +배포 후 1회 실행: + docker compose exec backend python backfill_swing_entry_fill.py # dry-run(기본, SELECT 만) + docker compose exec backend python backfill_swing_entry_fill.py --apply # 실제 갱신 + +- 대상 선정·계산은 `trade_executor.plan_swing_entry_backfill_v2`(순수 함수) 가 한다. + 체결가가 확정된 스윙 BUY 와 1:1(분할매수면 체결수량 합 = 행 수량)로 대응되고, 현재 진입가가 + 주문가 기준 그대로인 행만 보정한다. 그 외(시딩·DT·레거시 미확인·부분체결 등)는 사유만 출력한다. +- 쓰기는 db_worker 의 `upsert_active_trade` 핸들러(`_execute_db_task_impl`) 를 그대로 쓴다. + 이 스크립트는 앱 밖 프로세스라 앱의 워커 큐에 넣을 수 없으므로 같은 핸들러를 직접 실행한다. +- 스윙 사이클과 같은 행을 동시에 갱신하지 않도록 **장 마감 후** 실행을 권장한다. +""" +import argparse +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from app.core.database import AsyncSessionLocal # noqa: E402 +from app.services.trade_executor import ( # noqa: E402 + apply_swing_entry_backfill_v2, + collect_swing_entry_backfill_v2, +) + + +async def _submit_direct(payload: dict) -> None: + from app.services.db_worker import _execute_db_task_impl + await _execute_db_task_impl({"type": "upsert_active_trade", "data": payload}) + + +async def main(apply: bool) -> int: + async with AsyncSessionLocal() as db: + plans = await collect_swing_entry_backfill_v2(db) + for p in plans: + mark = "FIX " if p["payload"] else "SKIP" + print(f"{mark} id={p['active_id']} {p['stock_code']} [{p['strategy_name']}] " + f"entry={p['entry_price']} — {p['reason']}") + todo = sum(1 for p in plans if p["payload"]) + if not apply: + print(f"dry-run: 보정 대상 {todo}건 (--apply 로 실행)") + return 0 + n = await apply_swing_entry_backfill_v2(plans, submit=_submit_direct) + print(f"applied: {n}건") + return 0 + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--apply", action="store_true") + sys.exit(asyncio.run(main(ap.parse_args().apply))) diff --git a/backend/batch_daily_analysis.py b/backend/batch_daily_analysis.py index 6d38a12..9313484 100644 --- a/backend/batch_daily_analysis.py +++ b/backend/batch_daily_analysis.py @@ -237,8 +237,62 @@ async def get_all_tradable_stocks(limit: int = 500) -> list[tuple[str, str]]: # ------------------------------------------------------------------- CHART_COLLECT_CONCURRENCY = 5 # KIS API 동시 호출 수 (차트 수집용) +# KIS 일봉은 수정주가(FID_ORG_ADJ_PRC=0)라 원주가 테이블 stock_daily_prices 를 오염시킨다 +# (change_rate 는 KRX 원주가 기준이라 한 행에 기준이 섞임). 원주가 조회 _v2 가 생기기 전까지 OFF. +CHART_DB_UPSERT_ENABLED = False CHART_COLLECT_DAYS = 400 # 수집할 캘린더일 수 (약 240 거래일) +_MARKET_CONFIRM_HOUR_MIN = (15, 30) # 이후에는 당일 봉을 확정봉으로 간주 + + +def _bar_cutoff_date_v2(now_kst: datetime) -> date: + """저장 허용 마지막 거래일. 장 마감(15:30) 전이면 전일까지만(당일 미확정 봉 제외).""" + today = now_kst.date() + if (now_kst.hour, now_kst.minute) >= _MARKET_CONFIRM_HOUR_MIN: + return today + return today - timedelta(days=1) + + +def _to_int_v2(v) -> int: + return int(float(str(v if v is not None else "0").replace(",", ""))) + + +def _parse_kis_daily_record_v2( + item: dict, stock_code: str, stock_name: str, cutoff_date: date, +) -> Optional[dict]: + """KIS 일봉 1행 -> DB 레코드. 0/비정상 OHLC·미확정 당일 봉이면 None.""" + try: + s = item.get("stck_bsop_date", "") + if not s or len(s) != 8: + return None + td = date(int(s[:4]), int(s[4:6]), int(s[6:8])) + if td > cutoff_date: + return None + o = _to_int_v2(item.get("stck_oprc")) + h = _to_int_v2(item.get("stck_hgpr")) + l = _to_int_v2(item.get("stck_lwpr")) + c = _to_int_v2(item.get("stck_clpr")) + if min(o, h, l, c) <= 0: + return None + if h < l or h < max(o, c) or l > min(o, c): + return None + return { + "stock_code": stock_code, + "stock_name": stock_name, + "trade_date": td, + "open_price": o, + "high_price": h, + "low_price": l, + "close_price": c, + "volume": int(float(str(item.get("acml_vol", 0) or 0).replace(",", ""))), + "change_rate": None, + "market_cap": None, + } + except (ValueError, TypeError): + return None + + + async def _collect_chart_for_stock( stock_code: str, stock_name: str, @@ -253,6 +307,9 @@ async def _collect_chart_for_stock( from app.services.korea_investment_api import korea_investment_api from sqlalchemy.dialects.postgresql import insert as pg_insert + if not CHART_DB_UPSERT_ENABLED: + return stock_code, 0 + async with semaphore: try: raw_items = await korea_investment_api.get_daily_ohlcv_range( @@ -262,33 +319,13 @@ async def _collect_chart_for_stock( return stock_code, 0 records = [] + cutoff_date = _bar_cutoff_date_v2(datetime.now(KST)) for item in raw_items: - try: - trade_date_str = item.get("stck_bsop_date", "") - if not trade_date_str or len(trade_date_str) != 8: - continue - td = date( - int(trade_date_str[:4]), - int(trade_date_str[4:6]), - int(trade_date_str[6:8]), - ) - close_price = int(float(str(item.get("stck_clpr", "0")).replace(",", ""))) - if close_price == 0: - continue - records.append({ - "stock_code": stock_code, - "stock_name": stock_name, - "trade_date": td, - "open_price": int(float(str(item.get("stck_oprc", "0")).replace(",", ""))), - "high_price": int(float(str(item.get("stck_hgpr", "0")).replace(",", ""))), - "low_price": int(float(str(item.get("stck_lwpr", "0")).replace(",", ""))), - "close_price": close_price, - "volume": int(item.get("acml_vol", 0)), - "change_rate": None, - "market_cap": None, - }) - except (ValueError, TypeError): - continue + rec = _parse_kis_daily_record_v2( + item, stock_code, stock_name, cutoff_date, + ) + if rec is not None: + records.append(rec) if not records: return stock_code, 0 @@ -331,6 +368,10 @@ async def collect_chart_data_for_targets( Returns: 총 저장된 행 수 """ + if not CHART_DB_UPSERT_ENABLED: + logger.info("📊 차트 DB 수집 비활성(수정주가 오염 방지) — 건너뜀") + return 0 + from app.models.models import Portfolio # 1. 보유종목 조회 diff --git a/backend/collect_krx_data.py b/backend/collect_krx_data.py index 3c195e0..9965a4c 100644 --- a/backend/collect_krx_data.py +++ b/backend/collect_krx_data.py @@ -42,13 +42,35 @@ SKIP_KEYWORDS = [ "선물", "스팩", "리츠", "SPAC", ] +# 벤치마크/국면 판정용 ETF (SKIP_KEYWORDS 로 제외되지만 --include-benchmarks 시 허용). +# 기본 OFF: 켜면 이 종목들이 DB 스캐너·배치 유니버스에 들어간다(레버리지/인버스 포함). +BENCHMARK_ETF_CODES = frozenset({"069500", "122630", "252670", "114800"}) +_include_benchmarks = False +# 실전 유니버스 ETF(주식시세 API 에 없음) 중 ETF API 로 추가 수집할 코드. 기본 비어 있음(OFF). +_extra_etf_codes: frozenset = frozenset() +_etf_from_universe = False # True 면 collect_all_v2 시작 시 stock_analysis 기준 후보를 합친다 + +# 증분 모드에서 종목수 부족(한쪽 시장 누락) 날짜를 재검사할 달력일 범위와 판정 비율 +INCOMPLETE_LOOKBACK_DAYS = 90 +INCOMPLETE_LOCAL_WINDOW_DAYS = 10 # 지역 기준선: 전후 달력일 (상장 종목수 증가 추세 보정) +INCOMPLETE_RATIO = 0.85 + # 공공데이터포털 API 설정 DATAGOKR_BASE_URL = ( "https://apis.data.go.kr/1160100/service" "/GetStockSecuritiesInfoService/getStockPriceInfo" ) +# 벤치마크 ETF 는 주식시세 API 에 없을 가능성이 높아 증권상품시세(ETF) 서비스에서 별도 수집한다. +# 경로는 공개 문서 기억 기반의 추정이며 실호출로 확인되지 않았다 — 응답이 비면 warning 을 남긴다. +DATAGOKR_ETF_URL = ( + "https://apis.data.go.kr/1160100/service" + "/GetSecuritiesProductInfoService/getETFPriceInfo" +) ROWS_PER_PAGE = 1000 # API 최대 허용값 +# 수정주가 미반영(분할·병합·권리락) 이벤트 감지: |실제 종가비 - 1 - fltRt| 가 이 값을 넘으면 이벤트 +PRICE_EVENT_TOL = 0.25 + # 날짜별 요청 간격 (초) REQUEST_INTERVAL = 0.2 @@ -76,11 +98,18 @@ def generate_trading_dates(start: str, end: str) -> list[str]: # ─── 공공데이터포털 API 수집 ─────────────────────────────────────── async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame: + """기존 시그니처 유지 래퍼. 실패/무데이터 구분이 필요하면 fetch_daily_prices_v2.""" + df, _status = await fetch_daily_prices_v2(market, bas_dd) + return df + + +async def fetch_daily_prices_v2(market: str, bas_dd: str) -> tuple[pd.DataFrame, str]: """공공데이터포털 금융위원회 주식시세 API로 전종목 OHLCV 조회. market: 'kospi' | 'kosdaq' bas_dd: 'YYYYMMDD' - 반환: DataFrame (stock_code, stock_name, date, open, high, low, close, volume, change_rate) + 반환: (DataFrame, status) status = "ok"(데이터 있음) | "empty"(응답 정상·항목 없음) | "error"(요청 실패) + DataFrame 컬럼: (stock_code, stock_name, date, open, high, low, close, volume, change_rate) """ mrkt_cls = "KOSPI" if market == "kospi" else "KOSDAQ" api_key = settings.DATAGOKR_API_KEY @@ -93,6 +122,7 @@ async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame: trade_date = datetime.strptime(bas_dd, "%Y%m%d").date() rows: list[dict] = [] page = 1 + status = "empty" async with httpx.AsyncClient(timeout=30.0) as client: while True: @@ -110,6 +140,7 @@ async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame: data = resp.json() except Exception as exc: logger.error("API 요청 실패 [%s %s page%d]: %s", market, bas_dd, page, exc) + status = "error" break body = data.get("response", {}).get("body", {}) @@ -126,12 +157,12 @@ async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame: for item in items: name = item.get("itmsNm", "") - if any(kw in name for kw in SKIP_KEYWORDS): + code_raw = str(item.get("srtnCd", "")).strip().zfill(6) + keep_benchmark = _include_benchmarks and code_raw in BENCHMARK_ETF_CODES + if not keep_benchmark and any(kw in name for kw in SKIP_KEYWORDS): continue volume = int(item.get("trqu", 0) or 0) - if volume == 0: - continue # 거래 정지 close = int(item.get("clpr", 0) or 0) if close == 0: @@ -145,14 +176,23 @@ async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame: # srtnCd: 단축코드(6자리), 앞에 공백 있을 수 있어 strip stock_code = str(item.get("srtnCd", "")).strip().zfill(6) + o_, h_, l_, c_ = halted_aware_ohlc( + int(item.get("mkp", 0) or 0), + int(item.get("hipr", 0) or 0), + int(item.get("lopr", 0) or 0), + close, + volume, + ) + if volume == 0: + change_rate = 0.0 # 거래정지 봉: 가격 변동 없음(종가 = 직전 기준가) rows.append({ "stock_code": stock_code, "stock_name": name, "date": trade_date, - "open": int(item.get("mkp", 0) or 0), - "high": int(item.get("hipr", 0) or 0), - "low": int(item.get("lopr", 0) or 0), - "close": close, + "open": o_, + "high": h_, + "low": l_, + "close": c_, "volume": volume, "change_rate": change_rate, "market_cap": int(item.get("mrktTotAmt", 0) or 0) or None, @@ -166,9 +206,177 @@ async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame: page += 1 await asyncio.sleep(REQUEST_INTERVAL) + if rows: + status = "ok" if status != "error" else "error" # 중간 페이지 실패 = 불완전 + return (pd.DataFrame(rows) if rows else pd.DataFrame()), status + + +def normalize_ohlc(open_: int, high: int, low: int, close: int) -> tuple[int, int, int, int]: + """시가·고가·저가가 0(단일가 체결 등 미산출)이면 종가로 채우고 high/low 정합을 보장. 순수 함수. + + close 는 호출부에서 >0 보장. 0 OHLC 가 저장되면 백테스트 TR·손절 판정이 왜곡된다. + """ + o = open_ if open_ > 0 else close + h = high if high > 0 else close + l = low if low > 0 else close + h = max(h, o, l, close) + l = min(l, o, h, close) + return o, h, l, close + + +def halted_aware_ohlc(open_: int, high: int, low: int, close: int, + volume: int) -> tuple[int, int, int, int]: + """거래량 0(거래정지) 봉은 OHLC 를 종가로 평탄화해 저장한다. 순수 함수. + + 정지 봉을 버리면 재개일 봉이 정지 직전 봉에 바로 붙어 MA·RSI·ATR 기간이 짧아지고 + 재개 갭이 1봉 수익률이 된다. volume=0 봉은 소비처의 거래량 하한 게이트에서 진입이 거부된다. + """ + if volume <= 0: + return close, close, close, close + return normalize_ohlc(open_, high, low, close) + + +def is_price_event(close: float, prev_close: float, change_rate: float | None, + tol: float | None = None) -> bool: + """원주가 불연속(액면분할·병합·권리락) 판정. 순수 함수. + + fltRt(거래소 산출 등락률, 소수)와 실제 종가비 괴리가 tol 초과면 True. + stock_daily_prices 는 수정주가가 아니므로 이 구간의 수익률은 소비처에서 쓰면 안 된다. + """ + tol = PRICE_EVENT_TOL if tol is None else tol + if not prev_close or prev_close <= 0 or not close or close <= 0: + return False + if change_rate is None: + return False + return abs((close / prev_close - 1.0) - float(change_rate)) > tol + + +async def get_prev_closes(session, codes: list[str], before: date) -> dict[str, tuple[date, int]]: + """before 이전 종목별 마지막 종가 (읽기 전용).""" + if not codes: + return {} + result = await session.execute( + text( + "SELECT DISTINCT ON (stock_code) stock_code, trade_date, close_price " + "FROM stock_daily_prices WHERE trade_date < :before AND stock_code = ANY(:codes) " + "ORDER BY stock_code, trade_date DESC" + ), + {"before": before, "codes": list(codes)}, + ) + return {r[0]: (r[1], int(r[2])) for r in result.all()} + + +def detect_price_events(df: pd.DataFrame, prev: dict[str, tuple[date, int]], + max_gap_days: int = 4) -> list[tuple[str, date, float, float]]: + """수집 프레임과 직전 종가로 이벤트 목록 [(code, date, 실제등락, fltRt)]. 순수 함수.""" + out: list[tuple[str, date, float, float]] = [] + for _, row in df.iterrows(): + p = prev.get(row["stock_code"]) + if not p: + continue + d = row["date"].date() if isinstance(row["date"], pd.Timestamp) else row["date"] + if (d - p[0]).days > max_gap_days: + continue # 누락 구간은 이벤트가 아니라 갭 + cr = row["change_rate"] + cr = float(cr) if pd.notna(cr) else None + if is_price_event(float(row["close"]), float(p[1]), cr): + out.append((row["stock_code"], d, float(row["close"]) / p[1] - 1.0, cr)) + return out + + +async def fetch_benchmark_etf_prices(bas_dd: str) -> pd.DataFrame: + """벤치마크 ETF(BENCHMARK_ETF_CODES) 일봉 — 증권상품시세 API (경로 추정, 미검증).""" + return await fetch_etf_prices_v2(bas_dd, BENCHMARK_ETF_CODES) + + +async def fetch_etf_prices_v2(bas_dd: str, codes) -> pd.DataFrame: + """지정한 ETF/ETN 코드(codes)의 일봉 — 증권상품시세 API (경로 추정, 미검증). + + 전체 ETF 를 저장하면 DB 스캐너 유니버스가 오염되므로 반드시 codes 로 한정한다. + """ + wanted = {str(c).strip().zfill(6) for c in (codes or ())} + if not wanted: + return pd.DataFrame() + api_key = settings.DATAGOKR_API_KEY + if not api_key: + raise RuntimeError("DATAGOKR_API_KEY 가 설정되지 않았습니다.") + trade_date = datetime.strptime(bas_dd, "%Y%m%d").date() + rows: list[dict] = [] + page = 1 + async with httpx.AsyncClient(timeout=30.0) as client: + while True: + params = { + "serviceKey": api_key, "numOfRows": str(ROWS_PER_PAGE), + "pageNo": str(page), "resultType": "json", "basDt": bas_dd, + } + try: + resp = await client.get(DATAGOKR_ETF_URL, params=params) + resp.raise_for_status() + data = resp.json() + except Exception as exc: + logger.warning("ETF API 요청 실패 [%s page%d]: %s", bas_dd, page, exc) + break + body = data.get("response", {}).get("body", {}) + total_count = int(body.get("totalCount", 0) or 0) + items_wrap = body.get("items", {}) + if not items_wrap: + break + items = items_wrap.get("item", []) + if not isinstance(items, list): + items = [items] + for item in items: + code = str(item.get("srtnCd", "")).strip().zfill(6) + if code not in wanted: + continue + volume = int(float(item.get("trqu", 0) or 0)) + close = int(float(item.get("clpr", 0) or 0)) + if close == 0: + continue + if volume == 0: + cr = 0.0 + else: + try: + cr = float(item.get("fltRt", 0) or 0) / 100.0 + except (TypeError, ValueError): + cr = 0.0 + o_, h_, l_, c_ = halted_aware_ohlc( + int(float(item.get("mkp", 0) or 0)), + int(float(item.get("hipr", 0) or 0)), + int(float(item.get("lopr", 0) or 0)), + close, + volume, + ) + rows.append({ + "stock_code": code, "stock_name": item.get("itmsNm", ""), + "date": trade_date, + "open": o_, "high": h_, "low": l_, + "close": c_, "volume": volume, "change_rate": cr, + "market_cap": int(float(item.get("mrktTotAmt", 0) or 0)) or None, + }) + if (page - 1) * ROWS_PER_PAGE + len(items) >= total_count or len(items) < ROWS_PER_PAGE: + break + page += 1 + await asyncio.sleep(REQUEST_INTERVAL) return pd.DataFrame(rows) if rows else pd.DataFrame() +async def get_universe_etf_candidate_codes(session, lookback_days: int = 30) -> list[str]: + """실전 분석 유니버스(stock_analysis)에 있으나 일봉 DB 에 행이 없는 종목 코드 (읽기 전용 SELECT). + + 대부분 ETF/ETN 이다(추정: 주식시세 API 에 없음). --etf-from-universe 로 ETF API 수집 대상이 된다. + """ + since = date.today() - timedelta(days=lookback_days) + result = await session.execute( + text( + "SELECT DISTINCT a.stock_code FROM stock_analysis a " + "WHERE a.analysis_date >= :since AND NOT EXISTS (" + "SELECT 1 FROM stock_daily_prices p WHERE p.stock_code = a.stock_code)" + ), + {"since": since}, + ) + return sorted(str(r[0]).strip().zfill(6) for r in result.all()) + + # ─── DB 헬퍼 ────────────────────────────────────────────────────── async def create_tables(): @@ -232,6 +440,78 @@ async def upsert_daily_prices(session, df: pd.DataFrame) -> int: return inserted +def find_incomplete_dates( + counts: dict[date, int], + window_start: date, + window_end: date, + is_holiday=None, + ratio: float = INCOMPLETE_RATIO, +) -> list[date]: + """[window_start, window_end] 평일 중 행 수 누락/부족(한쪽 시장 누락 의심) 날짜. + + 기준 = 창 내 양수 행 수의 중앙값 x ratio. 휴장일(is_holiday)은 제외한다. + 순수 함수 (DB·네트워크 없음). + """ + positives = sorted(v for v in counts.values() if v > 0) + if not positives: + return [] + median = positives[len(positives) // 2] + threshold = median * ratio + out: list[date] = [] + d = window_start + while d <= window_end: + if d.weekday() < 5 and not (is_holiday and is_holiday(d)): + if counts.get(d, 0) < threshold: + out.append(d) + d += timedelta(days=1) + return out + + +def find_incomplete_dates_v2( + counts: dict[date, int], + window_start: date, + window_end: date, + is_holiday=None, + ratio: float = INCOMPLETE_RATIO, + local_days: int | None = None, +) -> list[date]: + """find_incomplete_dates 의 지역 기준선 버전. 순수 함수. + + 기준 = 해당 날짜 전후 local_days(달력일) 내 양수 행 수 중앙값 x ratio. + 넓은 창에서 전역 중앙값을 쓰면 종목수 증가 추세로 과거 정상일이 오탐된다. + 이웃이 없으면 창 전체 중앙값으로 폴백. 휴장일(is_holiday)·주말 제외. + """ + local_days = INCOMPLETE_LOCAL_WINDOW_DAYS if local_days is None else local_days + all_pos = sorted(v for v in counts.values() if v > 0) + if not all_pos: + return [] + global_median = all_pos[len(all_pos) // 2] + out: list[date] = [] + d = window_start + while d <= window_end: + if d.weekday() < 5 and not (is_holiday and is_holiday(d)): + neigh = sorted( + v for k, v in counts.items() + if v > 0 and k != d and abs((k - d).days) <= local_days + ) + base = neigh[len(neigh) // 2] if neigh else global_median + if counts.get(d, 0) < base * ratio: + out.append(d) + d += timedelta(days=1) + return out + + +async def get_recent_date_counts(session, since: date) -> dict[date, int]: + result = await session.execute( + text( + "SELECT trade_date, COUNT(*) FROM stock_daily_prices " + "WHERE trade_date >= :since GROUP BY trade_date" + ), + {"since": since}, + ) + return {r[0]: int(r[1]) for r in result.all()} + + # ─── 날짜별 수집 ────────────────────────────────────────────────── async def collect_date( @@ -251,13 +531,53 @@ async def collect_date( markets.append("kosdaq") frames: list[pd.DataFrame] = [] + statuses: dict[str, str] = {} for mkt in markets: - df = await fetch_daily_prices(mkt, bas_dd) + df, st = await fetch_daily_prices_v2(mkt, bas_dd) + statuses[mkt] = st if not df.empty: frames.append(df) await asyncio.sleep(REQUEST_INTERVAL) + if _include_benchmarks and frames: + try: + etf_df = await fetch_benchmark_etf_prices(bas_dd) + except Exception as exc: + logger.warning("[%s] 벤치마크 ETF 수집 실패: %s", bas_dd, exc) + etf_df = pd.DataFrame() + got = set(etf_df["stock_code"]) if not etf_df.empty else set() + if not got: + logger.warning( + "[%s] --include-benchmarks 인데 벤치마크 ETF(%s)가 응답에 없음 — " + "regime_hmm/stock_pattern_memory DB 폴백은 계속 빈 결과", + bas_dd, ",".join(sorted(BENCHMARK_ETF_CODES)), + ) + else: + frames.append(etf_df) + + if _extra_etf_codes and frames: + try: + extra_df = await fetch_etf_prices_v2(bas_dd, _extra_etf_codes) + except Exception as exc: + logger.warning("[%s] 유니버스 ETF 수집 실패: %s", bas_dd, exc) + extra_df = pd.DataFrame() + if extra_df.empty: + logger.warning( + "[%s] --etf-codes/--etf-from-universe 지정 %d종목이 ETF API 응답에 없음 " + "(엔드포인트 경로 미검증 — 실호출 확인 필요)", + bas_dd, len(_extra_etf_codes), + ) + else: + frames.append(extra_df) + + any_error = any(st == "error" for st in statuses.values()) + # 일부 시장만 비었거나 실패 = 불완전 (휴장일이면 전 시장이 비므로 해당 없음) + partial = bool(frames) and any(st != "ok" for st in statuses.values()) + if not frames: + if any_error: + logger.error("[%d/%d] %s: API 오류로 수집 실패 %s", i, total, bas_dd, statuses) + return False, 0 logger.info("[%d/%d] %s: 데이터 없음 (휴장일?)", i, total, bas_dd) return True, 0 @@ -265,8 +585,29 @@ async def collect_date( combined = combined.drop_duplicates(subset=["stock_code", "date"]) async with AsyncSessionLocal() as db: + # 수정주가 불연속 감지 (읽기 전용 · 경고 전용 — 저장은 막지 않는다) + try: + td = combined["date"].iloc[0] + td = td.date() if isinstance(td, pd.Timestamp) else td + prev = await get_prev_closes(db, list(combined["stock_code"]), td) + events = detect_price_events(combined, prev) + if events: + logger.warning( + "[%s] 원주가 불연속(분할/병합/권리락 의심) %d건 — 소비처에서 해당 구간 수익률 제외 필요: %s", + bas_dd, len(events), + [(c, round(a, 3), round(b, 3)) for c, _d, a, b in events[:10]], + ) + except Exception as exc: + logger.warning("[%s] 가격 이벤트 감지 실패(건너뜀): %s", bas_dd, exc) count = await upsert_daily_prices(db, combined) + if partial: + logger.warning( + "[%d/%d] %s: %d종목 저장했으나 불완전 %s — 다음 --update 에서 재수집", + i, total, bas_dd, count, statuses, + ) + return False, count + logger.info("[%d/%d] %s: %d종목 저장", i, total, bas_dd, count) return True, count @@ -283,24 +624,84 @@ async def collect_all( start_date: str = "20200101", update_mode: bool = False, ): - """메인 수집 로직 (공공데이터포털 금융위원회 주식시세 API).""" + """메인 수집 로직 (공공데이터포털 금융위원회 주식시세 API). 시그니처 유지 래퍼.""" + return await collect_all_v2( + market=market, limit=limit, start_date=start_date, update_mode=update_mode, + ) + + +async def collect_all_v2( + market: str = "all", + limit: int = 0, + start_date: str = "20200101", + update_mode: bool = False, + dates_override: list[str] | None = None, + rescan_days: int | None = None, +): + """collect_all + 명시 날짜 백필(dates_override) / 부분일 장기 재검사(rescan_days).""" await create_tables() + global _extra_etf_codes + if _etf_from_universe: + async with AsyncSessionLocal() as db: + cand = await get_universe_etf_candidate_codes(db) + _extra_etf_codes = frozenset(_extra_etf_codes | set(cand)) + logger.info("유니버스 ETF 후보 %d종목 추가 수집 대상", len(cand)) + + if dates_override: + # 1회성 백필: 지정 날짜만 재수집 (부분 수집일 복구용) + dates = sorted({d.strip() for d in dates_override if d.strip()}) + total = len(dates) + logger.info("명시 날짜 백필: %s", dates) + semaphore = asyncio.Semaphore(3) + results = await asyncio.gather( + *[collect_date(i, total, d, market, semaphore) for i, d in enumerate(dates, 1)], + return_exceptions=True, + ) + ok = sum(1 for r in results if isinstance(r, tuple) and r[0]) + logger.info("백필 완료: 성공 %d/%d", ok, total) + return + lookback = INCOMPLETE_LOOKBACK_DAYS if rescan_days is None else max(1, int(rescan_days)) + end_date = datetime.now().strftime("%Y%m%d") actual_start = start_date + extra_dates: list[str] = [] if update_mode: async with AsyncSessionLocal() as db: last_date = await get_last_trade_date(db) + counts: dict[date, int] = {} + if last_date and market == "all": + try: + since = date.today() - timedelta(days=lookback) + counts = await get_recent_date_counts(db, since) + except Exception as exc: + logger.warning("누락일 점검 실패(건너뜀): %s", exc) if last_date: + if market == "all" and counts: + try: + from app.core.market_schedule import is_krx_holiday + except Exception: + is_krx_holiday = None + gaps = find_incomplete_dates_v2( + counts, + date.today() - timedelta(days=lookback), + last_date, + is_holiday=is_krx_holiday, + ) + extra_dates = [g.strftime("%Y%m%d") for g in gaps] + if extra_dates: + logger.warning("불완전/누락 날짜 재수집 대상: %s", extra_dates) next_day = last_date + timedelta(days=1) actual_start = next_day.strftime("%Y%m%d") - if next_day >= date.today(): + if next_day >= date.today() and not extra_dates: logger.info("이미 최신 데이터입니다 (마지막: %s)", last_date) return logger.info("증분 업데이트: %s ~", actual_start) - dates = generate_trading_dates(actual_start, end_date) + dates = generate_trading_dates(actual_start, end_date) if actual_start <= end_date else [] + if extra_dates: + dates = sorted(set(dates) | set(extra_dates)) if not dates: logger.warning("수집할 날짜가 없습니다.") return @@ -350,11 +751,38 @@ def main(): "--start-date", default="20200101", help="수집 시작일 YYYYMMDD (기본: 20200101)", ) + parser.add_argument( + "--include-benchmarks", action="store_true", + help="벤치마크 ETF(069500 등)를 ETF 제외 필터에서 허용 (기본 OFF)", + ) + parser.add_argument( + "--etf-codes", default="", + help="쉼표 구분 ETF/ETN 6자리 코드를 ETF 시세 API 로 추가 수집(기본 OFF, 경로 미검증)", + ) + parser.add_argument( + "--etf-from-universe", action="store_true", + help="stock_analysis 에 있으나 일봉 DB 에 없는 종목(대부분 ETF)을 ETF API 로 추가 수집", + ) + parser.add_argument( + "--dates", default="", + help="쉼표 구분 YYYYMMDD 만 재수집 (부분 수집일 백필, 예: 20221020,20221101,20221102,20221103,20221104,20221107,20221130,20230120,20260625,20260720,20260914)", + ) + parser.add_argument( + "--rescan-days", type=int, default=0, + help="--update 시 부분 수집일 재검사 달력일 범위 (기본 %d, 예: 365)" % INCOMPLETE_LOOKBACK_DAYS, + ) parser.add_argument( "--update", action="store_true", help="증분 업데이트 모드 (마지막 저장일 이후만 수집)", ) args = parser.parse_args() + global _include_benchmarks + _include_benchmarks = bool(args.include_benchmarks) + global _extra_etf_codes + codes = {c.strip().zfill(6) for c in args.etf_codes.split(",") if c.strip()} + _extra_etf_codes = frozenset(codes) + global _etf_from_universe + _etf_from_universe = bool(args.etf_from_universe) logger.info("일봉 데이터 수집 시작 (공공데이터포털)") logger.info(" 시장: %s", args.market) @@ -363,11 +791,13 @@ def main(): logger.info(" 증분 모드: %s", args.update) start_time = time.time() - asyncio.run(collect_all( + asyncio.run(collect_all_v2( market=args.market, limit=args.limit, start_date=args.start_date, update_mode=args.update, + dates_override=[d for d in args.dates.split(",") if d.strip()] or None, + rescan_days=args.rescan_days or None, )) elapsed = time.time() - start_time logger.info("총 소요시간: %.1f초 (%.1f분)", elapsed, elapsed / 60) diff --git a/backend/run_backtest.py b/backend/run_backtest.py index eba648b..1af5a1d 100644 --- a/backend/run_backtest.py +++ b/backend/run_backtest.py @@ -907,6 +907,155 @@ def load_data_fdr( return bars_by_code, index_bars, all_dates +# --------------------------------------------------------------------------- +# point-in-time 유니버스 (DB, 생존편향 제거) — load_data_fdr 의 하드코딩 34종목 대체 +# --------------------------------------------------------------------------- +# 규칙(생존편향 제거): +# 1) 기준일(= 시뮬레이션 시작일 전일) 시점에 stock_daily_prices 에 존재한 종목만 후보. 이후 폐지된 종목 포함. +# 2) 순위는 기준일 직전 PIT_LIQUIDITY_LOOKBACK_DAYS 일 평균 거래대금(종가×거래량). 미래 정보 미사용. +# 3) 폐지 판정: 마지막 실제 봉이 데이터 종료일보다 PIT_DELIST_GRACE_DAYS 일 넘게 앞서면 폐지로 본다. +# 폐지 종목은 마지막 봉 다음 거래일에 종가 = 마지막 종가 × 회수율 인 종결 봉을 둔다. +# 회수율 기본 0.0(전액 손실, 최소 1원) — 합병·이전상장은 손실이 아닐 수 있으나 상방 편향을 막는 쪽을 택했다. +# 한계(추정): 기간 중 신규 상장 종목은 포함되지 않는다(정적 유니버스 시뮬레이터 제약). +PIT_LIQUIDITY_LOOKBACK_DAYS = 90 +PIT_MIN_HISTORY_BARS = 215 # 워밍업 340일 ≈ 230거래일 내에서 충족 가능한 값 +PIT_DELIST_GRACE_DAYS = 30 +PIT_DEFAULT_TOP_N = 200 +PIT_DEFAULT_DELIST_RECOVERY = 0.0 + + +def rank_pit_universe_v2(rows: List[Tuple[str, str, float, int]], top_n: int) -> List[Tuple[str, str]]: + """(code, name, avg_traded_value, bar_count) -> 거래대금 상위 top_n [(code, name)]. 이력 부족 종목 제외.""" + ok = [r for r in rows if r[3] >= PIT_MIN_HISTORY_BARS and r[2] is not None and r[2] > 0] + ok.sort(key=lambda r: (-r[2], r[0])) + return [(r[0], r[1] or r[0]) for r in ok[:top_n]] + + +def apply_delisting_writeoff_v2( + bars_by_code: Dict[str, List[BarData]], + data_end: date, + recovery: float = PIT_DEFAULT_DELIST_RECOVERY, + grace_days: int = PIT_DELIST_GRACE_DAYS, +) -> List[str]: + """폐지로 보이는 종목에 종결 봉을 추가한다. 반환: 폐지 처리된 종목 코드. + + 종결 봉 날짜는 data_end 이전 달력일 중 마지막 봉 다음 날(calendar +1)이 아니라, + all_dates 를 오염시키지 않도록 호출자가 all_dates 계산 전에 호출해도 되게 last+1일로 둔다. + """ + recovery = min(max(float(recovery), 0.0), 1.0) + delisted: List[str] = [] + for code, bl in bars_by_code.items(): + if not bl: + continue + last = bl[-1] + if (data_end - last.trade_date).days <= grace_days: + continue + px = max(1.0, float(last.close_price) * recovery) + bl.append(BarData( + trade_date=last.trade_date + timedelta(days=1), + open_price=px, high_price=px, low_price=px, close_price=px, + volume=0, stock_code=code, stock_name=last.stock_name, + )) + delisted.append(code) + return delisted + + +def _db_url_for_asyncpg_v2() -> str: + url = os.environ.get("DATABASE_URL", "") + if not url: + raise RuntimeError("DATABASE_URL 미설정 (읽기 전용 계정 사용)") + return url.replace("postgresql+asyncpg://", "postgresql://") + + +async def _fetch_pit_v2(asof: date, start: date, end: date, top_n: int): + import asyncpg + conn = await asyncpg.connect(_db_url_for_asyncpg_v2(), ssl=False) + try: + ranking = await conn.fetch( + """ + SELECT stock_code, max(stock_name) AS name, + avg(close_price::float8 * volume) FILTER (WHERE trade_date > $2) AS tv, + count(*) AS n + FROM stock_daily_prices + WHERE trade_date <= $1 AND trade_date >= $3 + GROUP BY stock_code + HAVING max(trade_date) > $2 + """, + asof, asof - timedelta(days=PIT_LIQUIDITY_LOOKBACK_DAYS), start, + ) + picked = rank_pit_universe_v2( + [(r["stock_code"], r["name"], r["tv"], r["n"]) for r in ranking], top_n, + ) + codes = [c for c, _ in picked] + rows = await conn.fetch( + """SELECT stock_code, trade_date, open_price, high_price, low_price, close_price, volume + FROM stock_daily_prices + WHERE stock_code = ANY($1::text[]) AND trade_date BETWEEN $2 AND $3 + AND close_price > 0 + ORDER BY stock_code, trade_date""", + codes, start, end, + ) + return picked, rows + finally: + await conn.close() + + +def load_data_db_pit_v2( + start_date: str, + end_date: str, + sim_start: date, + top_n: int = PIT_DEFAULT_TOP_N, + recovery: float = PIT_DEFAULT_DELIST_RECOVERY, + index_code: str = KODEX200_CODE, +) -> Tuple[Dict[str, List[BarData]], List[BarData], List[date], Dict[str, Tuple[str, str]]]: + """DB 기반 point-in-time 유니버스 로드 (SELECT 전용). 지수는 기존처럼 FDR. + + Returns: (bars_by_code, index_bars, all_dates, universe) + """ + import asyncio + d0 = datetime.strptime(start_date, "%Y-%m-%d").date() + d1 = datetime.strptime(end_date, "%Y-%m-%d").date() + picked, rows = asyncio.run(_fetch_pit_v2(sim_start - timedelta(days=1), d0, d1, top_n)) + names = dict(picked) + bars_by_code: Dict[str, List[BarData]] = {} + for r in rows: + bars_by_code.setdefault(r["stock_code"], []).append(BarData( + trade_date=r["trade_date"], + open_price=float(r["open_price"] or r["close_price"]), + high_price=float(r["high_price"] or r["close_price"]), + low_price=float(r["low_price"] or r["close_price"]), + close_price=float(r["close_price"]), + volume=int(r["volume"] or 0), + stock_code=r["stock_code"], stock_name=names.get(r["stock_code"], ""), + )) + delisted = apply_delisting_writeoff_v2(bars_by_code, d1, recovery) + print(f" PIT 유니버스: {len(bars_by_code)}종목 (폐지 처리 {len(delisted)}, 회수율 {recovery})") + + idx_df = fdr.DataReader(index_code, start_date, end_date) + if idx_df is None or idx_df.empty: + print("FAIL (KODEX200 데이터 없음)") + sys.exit(1) + index_bars = [BarData( + trade_date=(dt.date() if hasattr(dt, "date") else dt), + open_price=float(row.get("Open", 0)), high_price=float(row.get("High", 0)), + low_price=float(row.get("Low", 0)), close_price=float(row.get("Close", 0)), + volume=int(row.get("Volume", 0)), stock_code=index_code, stock_name="KODEX 200", + ) for dt, row in idx_df.iterrows()] + # 거래일 달력은 지수 기준(종결 봉 날짜가 달력을 늘리지 않게 한다) + 실제 봉 날짜 + idx_dates = {b.trade_date for b in index_bars} + all_dates = sorted(idx_dates) + for code, bl in bars_by_code.items(): + # 종결 봉이 달력 밖이면 다음 거래일로 이동 + if delisted and code in delisted and bl[-1].trade_date not in idx_dates: + nxt = next((d for d in all_dates if d > bl[-2].trade_date), None) + if nxt is None: + bl.pop() + else: + bl[-1].trade_date = nxt + universe = {c: (names.get(c, c), "KOSPI") for c in bars_by_code} + return bars_by_code, index_bars, all_dates, universe + + def align_bars_to_dates( bars_by_code: Dict[str, List[BarData]], all_dates: List[date], @@ -1491,6 +1640,7 @@ def save_results_json( end_date: str, initial_capital: float, output_dir: str, + universe: Optional[Dict[str, Tuple[str, str]]] = None, ): ts = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"backtest_{start_date}_{end_date}_{ts}.json" @@ -1542,7 +1692,7 @@ def save_results_json( "end_date": end_date, "initial_capital": initial_capital, "strategy": "RSI+BB Mean Reversion (CLAUDE.md Section 6)", - "universe": {k: {"name": v[0], "market": v[1]} for k, v in BACKTEST_UNIVERSE.items()}, + "universe": {k: {"name": v[0], "market": v[1]} for k, v in (universe if universe is not None else BACKTEST_UNIVERSE).items()}, "params": { "rsi_period": RSI_PERIOD, "bb_period": BB_PERIOD, @@ -1582,6 +1732,11 @@ def main(): parser.add_argument("--start", type=str, default="2023-03-25", help="시작일 (YYYY-MM-DD)") parser.add_argument("--end", type=str, default="2026-03-25", help="종료일 (YYYY-MM-DD)") parser.add_argument("--capital", type=int, default=INITIAL_CAPITAL, help="초기 자본금 (원)") + parser.add_argument("--universe", choices=["fdr", "db-pit"], default="fdr", + help="fdr=하드코딩 34종목(생존편향 있음), db-pit=DB point-in-time(폐지 종목 포함)") + parser.add_argument("--top-n", type=int, default=PIT_DEFAULT_TOP_N, help="db-pit 거래대금 상위 N") + parser.add_argument("--delist-recovery", type=float, default=PIT_DEFAULT_DELIST_RECOVERY, + help="폐지 종목 회수율(0~1, 기본 0=전액 손실)") args = parser.parse_args() start_date = args.start @@ -1593,7 +1748,7 @@ def main(): print(" Auto-Stock RSI+BB 평균회귀 백테스트") print(f" 기간: {start_date} ~ {end_date}") print(f" 자본: {initial_capital:,}원") - print(f" 종목: {len(BACKTEST_UNIVERSE)}종목") + print(f" 종목: {args.top_n if args.universe == 'db-pit' else len(BACKTEST_UNIVERSE)}종목 (유니버스={args.universe})") print("=" * 60) t0 = time.time() @@ -1606,9 +1761,16 @@ def main(): from datetime import timedelta as _td _sim_start = _dt.strptime(start_date, "%Y-%m-%d").date() _warmup_start = (_sim_start - _td(days=340)).strftime("%Y-%m-%d") - bars_by_code, index_bars, all_dates = load_data_fdr( - BACKTEST_UNIVERSE, _warmup_start, end_date, - ) + universe = BACKTEST_UNIVERSE + if args.universe == "db-pit": + bars_by_code, index_bars, all_dates, universe = load_data_db_pit_v2( + _warmup_start, end_date, _sim_start, args.top_n, args.delist_recovery, + ) + else: + print(" [경고] fdr 유니버스는 현재 상장 종목뿐 — 생존편향. §8.4 재실행 근거로 쓰지 말 것.") + bars_by_code, index_bars, all_dates = load_data_fdr( + BACKTEST_UNIVERSE, _warmup_start, end_date, + ) if not bars_by_code: print("\n [ERROR] 데이터 수집 실패. 종목 데이터를 가져올 수 없습니다.") @@ -1623,14 +1785,14 @@ def main(): # 3. 시뮬레이션 print(f"\n 시뮬레이션 시작...") metrics, all_trades, equity_curve, drawdown_curve, open_positions, mode_changes = run_simulation( - aligned, index_bars, all_dates, initial_capital, BACKTEST_UNIVERSE, + aligned, index_bars, all_dates, initial_capital, universe, sim_start_date=_sim_start, ) elapsed = time.time() - t0 # 4. 종목별 집계 - stock_summary = per_stock_summary(all_trades, BACKTEST_UNIVERSE) + stock_summary = per_stock_summary(all_trades, universe) # 적응형 모드 전환 이력 출력 if mode_changes: @@ -1658,6 +1820,7 @@ def main(): metrics, all_trades, equity_curve, drawdown_curve, stock_summary, open_positions, start_date, end_date, initial_capital, output_dir, + universe=universe, ) diff --git a/backend/tests/test_adaptive_min_bars_r3_20261009.py b/backend/tests/test_adaptive_min_bars_r3_20261009.py new file mode 100644 index 0000000..a1e18b2 --- /dev/null +++ b/backend/tests/test_adaptive_min_bars_r3_20261009.py @@ -0,0 +1,55 @@ +"""R3: KODEX200 봉수 부족 시 변동비 공식 폴백 대신 갱신 실패(staleness 경로).""" +import pytest +import app.services.adaptive_strategy as ad + + +def _rows(n): + return [{"stck_clpr": "100", "stck_hgpr": "101", "stck_lwpr": "99", "acml_vol": "1"} for _ in range(n)] + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch): + async def _no(*a, **k): + raise AssertionError("must not run") + monkeypatch.setattr(ad, "_save_to_redis", _no) + monkeypatch.setattr(ad, "_save_data_source_v2", _no) + monkeypatch.setattr(ad, "_current_params", None) + + +@pytest.mark.asyncio +async def test_short_history_is_failure(monkeypatch): + async def short(): return _rows(30), "kis_range" + monkeypatch.setattr(ad, "_fetch_kodex_chart_v2", short) + called = [] + monkeypatch.setattr(ad, "enforce_staleness_v2", lambda: called.append(1) or None) + def _boom(*a, **k): raise AssertionError("analyze must not run") + monkeypatch.setattr(ad, "analyze_market_condition", _boom) + before = ad._last_success_ts + await ad.refresh_adaptive_params() + assert called == [1] and ad._last_success_ts == before + + +@pytest.mark.asyncio +async def test_fetch_falls_through_to_kis_range(monkeypatch): + import app.services.naver_finance as nf + import app.services.korea_investment_api as k + async def naver(*a, **kw): return _rows(70) + monkeypatch.setattr(nf, "get_naver_daily_chart", naver) + class Api: + async def get_daily_ohlcv_range(self, *a): return _rows(ad._MIN_BARS_FOR_ATR_PCT + 5) + monkeypatch.setattr(k, "real_market_api", Api()) + data, src = await ad._fetch_kodex_chart_v2() + assert src == "kis_range" and len(data) == ad._MIN_BARS_FOR_ATR_PCT + 5 + + +@pytest.mark.asyncio +async def test_fetch_naver_sufficient_skips_kis(monkeypatch): + import app.services.naver_finance as nf + import app.services.korea_investment_api as k + async def naver(*a, **kw): return _rows(200) + monkeypatch.setattr(nf, "get_naver_daily_chart", naver) + class Api: + async def get_daily_ohlcv_range(self, *a): raise AssertionError("no kis") + monkeypatch.setattr(k, "real_market_api", Api()) + data, src = await ad._fetch_kodex_chart_v2() + assert src == "naver" and len(data) == 200 diff --git a/backend/tests/test_adaptive_staleness_r2_20261009.py b/backend/tests/test_adaptive_staleness_r2_20261009.py new file mode 100644 index 0000000..5eddf53 --- /dev/null +++ b/backend/tests/test_adaptive_staleness_r2_20261009.py @@ -0,0 +1,83 @@ +"""R2 2026-10-09: 적응형 모드 갱신 장기 실패 시 Defensive 강등 + 경보 (Crisis fail-open 방지).""" +import time +import pytest + +import app.services.adaptive_strategy as ad +from app.services.adaptive_strategy import TradingMode + + +@pytest.fixture(autouse=True) +def _state(monkeypatch): + monkeypatch.setattr(ad, "_current_params", ad.get_adaptive_params( + ad.MarketCondition(trading_mode=TradingMode.NORMAL.value))) + monkeypatch.setattr(ad, "_last_update_ts", time.monotonic()) + monkeypatch.setattr(ad, "_last_stale_alert_ts", 0.0) + monkeypatch.setattr(ad, "_market_open_or_true_v2", lambda: True) + alerts = [] + monkeypatch.setattr(ad, "_spawn_bg_v2", lambda coro: (alerts.append(1), coro.close())) + # 텔레그램 실호출 방지: 코루틴은 만들되 실행하지 않는다(_spawn_bg_v2 목이 close) + import app.services.telegram_notifier as tn + + async def _boom(*a, **k): + raise AssertionError("telegram must not run") + + monkeypatch.setattr(tn.TelegramNotifier, "notify_system_warning", _boom) + return alerts + + +def _stale(monkeypatch): + monkeypatch.setattr(ad, "_last_success_ts", time.monotonic() - ad._STALE_LIMIT_SEC - 1) + + +def test_fresh_keeps_mode(monkeypatch, _state): + monkeypatch.setattr(ad, "_last_success_ts", time.monotonic()) + assert ad.enforce_staleness_v2().trading_mode == TradingMode.NORMAL.value + assert not _state + + +def test_stale_in_market_degrades_and_alerts(monkeypatch, _state): + _stale(monkeypatch) + p = ad.enforce_staleness_v2() + assert p.trading_mode == TradingMode.DEFENSIVE.value + assert p.fallback_used is False + assert _state == [1] + ad.enforce_staleness_v2() # 경보 간격 내 재호출은 중복 경보 없음 + assert _state == [1] + + +def test_stale_outside_market_keeps(monkeypatch, _state): + monkeypatch.setattr(ad, "_market_open_or_true_v2", lambda: False) + _stale(monkeypatch) + assert ad.enforce_staleness_v2().trading_mode == TradingMode.NORMAL.value + + +def test_crisis_not_loosened(monkeypatch, _state): + ad.force_mode_override("crisis", "t") + _stale(monkeypatch) + assert ad.enforce_staleness_v2().trading_mode == TradingMode.CRISIS.value + + +@pytest.mark.asyncio +async def test_refresh_chart_failure_degrades(monkeypatch, _state): + """차트 조회 전부 실패(데이터 부족) 경로에서도 staleness 강등이 적용된다.""" + import app.services.naver_finance as nf + import app.services.korea_investment_api as kia + + async def _naver(*a, **k): + raise RuntimeError("naver down") + + class _Api: + async def get_daily_chart(self, *a, **k): + raise RuntimeError("kis down") + + monkeypatch.setattr(nf, "get_naver_daily_chart", _naver) + monkeypatch.setattr(kia, "real_market_api", _Api()) + _stale(monkeypatch) + p = await ad.refresh_adaptive_params() + assert p.trading_mode == TradingMode.DEFENSIVE.value + + +def test_get_current_ttl_expired_degrades(monkeypatch, _state): + monkeypatch.setattr(ad, "_last_update_ts", time.monotonic() - ad._CACHE_TTL - 5) + _stale(monkeypatch) + assert ad.get_current_adaptive_params().trading_mode == TradingMode.DEFENSIVE.value diff --git a/backend/tests/test_ai_pre_market_latest_date.py b/backend/tests/test_ai_pre_market_latest_date.py new file mode 100644 index 0000000..ad28bd7 --- /dev/null +++ b/backend/tests/test_ai_pre_market_latest_date.py @@ -0,0 +1,65 @@ +"""pre_market_analysis: 최신 분석일 1일치 + 종목당 1행 (회귀).""" +import asyncio +from datetime import date +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.services import ai_strategy_service as svc + + +def _row(code, score, d): + return SimpleNamespace(stock_code=code, stock_name=code, score=score, rsi=40, + current_price=10000, analysis_date=d, signals=[]) + + +def test_dedupe_keeps_first_per_stock_and_limit(): + rows = [_row("A", 9, 1), _row("A", 8, 2), _row("B", 7, 1), _row("C", 6, 1)] + out = svc._dedupe_latest_per_stock_v2(rows, 2) + assert [r.stock_code for r in out] == ["A", "B"] + + +def test_query_filters_on_latest_date_and_dedupes(): + latest = date(2026, 10, 8) + stmts = [] + + class Res: + def __init__(self, v): + self.v = v + + def scalar(self): + return self.v + + def scalars(self): + return SimpleNamespace(all=lambda: self.v) + + class Sess: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, stmt): + stmts.append(str(stmt.compile(compile_kwargs={"literal_binds": True}))) + if len(stmts) == 1: + return Res(latest) + return Res([_row("A", 9, latest), _row("A", 8, latest), _row("B", 7, latest)]) + + captured = {} + + async def fake_news(code, limit=3): + return [] + + async def fake_mcp(batch, context=""): + captured["batch"] = batch + return [] + + with patch("app.core.database.AsyncSessionLocal", lambda: Sess()), \ + patch.object(svc, "_fetch_stock_news_titles", fake_news), \ + patch.object(svc, "_call_mcp_batch_analysis", fake_mcp), \ + patch("app.core.redis.setex_json", AsyncMock()): + asyncio.run(svc.pre_market_analysis()) + + assert "max(" in stmts[0].lower() + assert "analysis_date =" in stmts[1] and "2026-10-08" in stmts[1] + assert [s["stock_code"] for s in captured["batch"]] == ["A", "B"] diff --git a/backend/tests/test_analysis_db_updated_at_kst.py b/backend/tests/test_analysis_db_updated_at_kst.py new file mode 100644 index 0000000..dd60294 --- /dev/null +++ b/backend/tests/test_analysis_db_updated_at_kst.py @@ -0,0 +1,19 @@ +"""to_analysis_dict.updated_at 은 KST naive (core.timeutil 규약).""" +from datetime import date, timedelta +from types import SimpleNamespace + +from app.core.timeutil import now_kst +from app.services import analysis_db_service as svc + + +def test_updated_at_is_kst_naive(): + r = SimpleNamespace(**{k: None for k in ( + "current_price", "change_rate", "volume", "rsi", "ma5", "ma20", "ma60", + "stochastic_k", "stochastic_d", "bollinger_upper", "bollinger_lower", + "bollinger_middle", "signals", "score", "ml_score", "ml_confidence", + "ml_signal", "atr_14", "adx_14")}) + before = now_kst() + d = svc.to_analysis_dict("005930", "x", r, date.today()) + ts = d["updated_at"] + assert ts.tzinfo is None + assert before - timedelta(seconds=1) <= ts <= now_kst() + timedelta(seconds=1) diff --git a/backend/tests/test_analysis_endpoint_v2_20261009.py b/backend/tests/test_analysis_endpoint_v2_20261009.py new file mode 100644 index 0000000..aef0177 --- /dev/null +++ b/backend/tests/test_analysis_endpoint_v2_20261009.py @@ -0,0 +1,30 @@ +"""analysis 엔드포인트가 v2 성과 분석을 호출하는지 검증. v1 호출 시 예외(공통 종단 차단).""" +import pytest + +from app.api.endpoints import analysis as an + + +async def _v1_blocked(*a, **k): + raise AssertionError("v1 analyze_performance 호출됨") + + +@pytest.mark.asyncio +async def test_performance_v2_and_insights_use_v2(monkeypatch): + monkeypatch.setattr(an, "analyze_performance", _v1_blocked) + calls = [] + + async def v2(db, days=30): + calls.append(days) + return {"summary": {"win_rate": 55.0, "profit_factor": 2.0, "gross_profit": 1, "gross_loss": 1}} + + async def last(db): + return None + + monkeypatch.setattr(an, "analyze_performance_v2", v2) + monkeypatch.setattr(an, "get_last_optimization_date", last) + + r = await an.get_performance_v2(days=7, db=None) + assert r["summary"]["win_rate"] == 55.0 + r = await an.get_insights(days=14, db=None) + assert "error" not in r and r["performance_summary"]["win_rate"] == 55.0 + assert calls == [7, 14] diff --git a/backend/tests/test_api_endpoints.py b/backend/tests/test_api_endpoints.py index 52b3953..b35f9f3 100644 --- a/backend/tests/test_api_endpoints.py +++ b/backend/tests/test_api_endpoints.py @@ -748,7 +748,7 @@ class TestTradeHistoryEndpoints: response = await client.delete("/api/v1/trades/pending") assert response.status_code == 200 data = response.json() - assert data["deleted_count"] == 1 # FAILED 1건 + assert data["deleted_count"] == 1 # FAILED 1건 (PENDING 보존) assert "message" in data # 정리 후 FAILED 거래가 없어야 함 diff --git a/backend/tests/test_background_tasks_r3_20261009.py b/backend/tests/test_background_tasks_r3_20261009.py new file mode 100644 index 0000000..266ae87 --- /dev/null +++ b/backend/tests/test_background_tasks_r3_20261009.py @@ -0,0 +1,46 @@ +import asyncio +import gc +import inspect + +import pytest + +from app.core import background_tasks as bt + + +@pytest.mark.asyncio +async def test_spawn_tracked_holds_strong_ref_and_discards(): + ev = asyncio.Event() + + async def work(): + await ev.wait() + + t = bt.spawn_tracked_v2(work()) + gc.collect() + assert t in bt._tracked + ev.set() + await t + await asyncio.sleep(0) + assert t not in bt._tracked + + +@pytest.mark.asyncio +async def test_registered_in_main_drain_set(): + import app.main as main + ev = asyncio.Event() + + async def work(): + await ev.wait() + + t = bt.spawn_tracked_v2(work()) + assert t in main._background_tasks + ev.set() + await t + await asyncio.sleep(0) + assert t not in main._background_tasks + + +def test_manual_buy_uses_tracked_spawn(): + from app.api.endpoints import manual_buy + src = inspect.getsource(manual_buy) + assert "asyncio.create_task(_delayed_sync_portfolio" not in src + assert "spawn_tracked_v2(_delayed_sync_portfolio" in src diff --git a/backend/tests/test_batch_daily_kis_record.py b/backend/tests/test_batch_daily_kis_record.py new file mode 100644 index 0000000..e34485d --- /dev/null +++ b/backend/tests/test_batch_daily_kis_record.py @@ -0,0 +1,83 @@ +"""batch_daily_analysis KIS 일봉 레코드 검증 회귀 테스트 (순수 함수, 외부 호출 없음).""" +import sys +from datetime import date, datetime +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import pytz +import batch_daily_analysis as b + +KST = pytz.timezone("Asia/Seoul") + + +def _item(d="20261008", o="100", h="110", l="95", c="105", v="1000"): + return {"stck_bsop_date": d, "stck_oprc": o, "stck_hgpr": h, + "stck_lwpr": l, "stck_clpr": c, "acml_vol": v} + + +CUT = date(2026, 10, 8) + + +def test_valid_row_kept(): + r = b._parse_kis_daily_record_v2(_item(), "005930", "x", CUT) + assert r and r["open_price"] == 100 and r["change_rate"] is None + + +def test_zero_ohlc_skipped(): + for k in ("o", "h", "l", "c"): + assert b._parse_kis_daily_record_v2(_item(**{k: "0"}), "A", "x", CUT) is None + + +def test_inconsistent_bar_skipped(): + assert b._parse_kis_daily_record_v2(_item(h="90", l="95"), "A", "x", CUT) is None + assert b._parse_kis_daily_record_v2(_item(h="100", c="105"), "A", "x", CUT) is None + + +def test_today_bar_excluded_until_close(): + open_now = KST.localize(datetime(2026, 10, 9, 10, 0)) + closed_now = KST.localize(datetime(2026, 10, 9, 15, 30)) + assert b._bar_cutoff_date_v2(open_now) == date(2026, 10, 8) + assert b._bar_cutoff_date_v2(closed_now) == date(2026, 10, 9) + assert b._parse_kis_daily_record_v2( + _item(d="20261009"), "A", "x", b._bar_cutoff_date_v2(open_now)) is None + assert b._parse_kis_daily_record_v2( + _item(d="20261009"), "A", "x", b._bar_cutoff_date_v2(closed_now)) is not None + + +def test_bad_date_or_value_skipped(): + assert b._parse_kis_daily_record_v2(_item(d="2026"), "A", "x", CUT) is None + assert b._parse_kis_daily_record_v2(_item(c="abc"), "A", "x", CUT) is None + + +def test_chart_upsert_disabled_by_default_no_kis_no_db(monkeypatch): + """수정주가가 원주가 테이블을 덮어쓰지 않도록 기본 OFF: KIS·DB 접근 시 예외.""" + import asyncio + + def boom(*a, **k): + raise AssertionError("KIS/DB 접근 금지") + + import app.services.korea_investment_api as kia + monkeypatch.setattr(kia.KoreaInvestmentAPI, "get_daily_ohlcv_range", boom) + monkeypatch.setattr(b, "AsyncSessionLocal", boom) + assert b.CHART_DB_UPSERT_ENABLED is False + r = asyncio.run(b._collect_chart_for_stock("005930", "x", "20250101", "20261008", asyncio.Semaphore(1))) + assert r == ("005930", 0) + + +def test_chart_upsert_disabled_makes_no_db_or_kis_calls(monkeypatch): + """수정주가(KIS)가 원주가 테이블을 덮어쓰지 않도록 기본 OFF, DB/KIS 접근은 예외 발생형으로 차단.""" + import asyncio + + assert b.CHART_DB_UPSERT_ENABLED is False + + def boom(*a, **k): + raise AssertionError("DB/KIS 접근 금지") + + monkeypatch.setattr(b, "AsyncSessionLocal", boom) + from app.services.korea_investment_api import KoreaInvestmentAPI + monkeypatch.setattr(KoreaInvestmentAPI, "get_daily_ohlcv_range", boom) + + assert asyncio.run(b.collect_chart_data_for_targets([("005930", "x")])) == 0 + sem = asyncio.Semaphore(1) + assert asyncio.run(b._collect_chart_for_stock("005930", "x", "20260101", "20261001", sem)) == ("005930", 0) diff --git a/backend/tests/test_bear_v2_hardening_r2_20261009.py b/backend/tests/test_bear_v2_hardening_r2_20261009.py new file mode 100644 index 0000000..b253475 --- /dev/null +++ b/backend/tests/test_bear_v2_hardening_r2_20261009.py @@ -0,0 +1,105 @@ +"""하드닝 루프 R2 (2026-10-09) — mean_reversion_strategy BEAR v2 회귀 테스트. + +1. BEAR v2 진입 description 에 f-string `%%B` 리터럴이 남아 저널·ML 텍스트에 '%%B' 로 기록되던 결함. +2. MEAN_REVERSION_BEAR_V2_ENABLED 가 실전 진입 경로에 배선되어 있지 않다는 사실(백테스트 전용)을 + 고정한다. 누군가 실전 경로에 배선하면 이 테스트가 깨져 문서(docstring·config·CLAUDE.md §6.1) + 갱신을 강제한다. +""" +import ast +import os +import re +from unittest.mock import MagicMock, patch + +import pytest + +from app.core.config import settings +import app.services.mean_reversion_strategy as mrs + +BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +@pytest.fixture(autouse=True) +def _cfg_none(): + """strategy_cfg(DB) 를 비워 settings 값으로 폴백시킨다(테스트 시점 바인딩).""" + _m = MagicMock() + _m.get.side_effect = lambda key, default=None: None + with patch.object(mrs, "strategy_cfg", _m): + yield + + +def _full_score_signal(): + bear_thr = settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD + rsi = min(bear_thr, settings.MEAN_REVERSION_RSI_STRONG_THRESHOLD) - 5.0 + avg_vol = 1_000_000 + cur_vol = int(avg_vol * (settings.MEAN_REVERSION_BEAR_VOLUME_MULT + 1.0)) + return mrs.check_bear_buy_signal_v2( + rsi=rsi, + close=10_100.0, # %B = 0.05 + bb_upper=12_000.0, + bb_lower=10_000.0, + avg_volume_20d=avg_vol, + current_volume=cur_vol, + is_reversal=True, + rsi_prev=rsi - 2.0, + bear_rsi_threshold=bear_thr, + ) + + +def test_bear_v2_description_has_single_percent_b(): + sig = _full_score_signal() + assert sig is not None, "만점 입력에서 BEAR v2 신호가 나야 한다" + desc = sig["description"] + assert "%%B" not in desc + assert re.search(r"\+ %B\(0\.05/", desc), desc + + +def _calls_in(path, names): + with open(path, encoding="utf-8") as f: + tree = ast.parse(f.read()) + hits = [] + for node in ast.walk(tree): + if isinstance(node, ast.Call): + fn = node.func + nm = fn.id if isinstance(fn, ast.Name) else (fn.attr if isinstance(fn, ast.Attribute) else None) + if nm in names: + hits.append(nm) + return hits + + +def test_bear_v2_is_backtest_only(): + """check_buy_signal / check_bear_buy_signal_v2 의 실전 호출부가 없음을 고정한다.""" + allowed = { + os.path.join(BACKEND_DIR, "app", "services", "mean_reversion_strategy.py"), + os.path.join(BACKEND_DIR, "app", "services", "kr_backtest_engine.py"), + os.path.join(BACKEND_DIR, "run_backtest.py"), + } + names = {"check_buy_signal", "check_bear_buy_signal_v2", "_bear_v2_enabled"} + offenders = [] + for root, dirs, files in os.walk(BACKEND_DIR): + dirs[:] = [d for d in dirs if d not in ("tests", ".venv", "venv", "__pycache__", "node_modules")] + for fn in files: + if not fn.endswith(".py"): + continue + p = os.path.join(root, fn) + if p in allowed: + continue + try: + hits = _calls_in(p, names) + except SyntaxError: + continue + if hits: + offenders.append((os.path.relpath(p, BACKEND_DIR), hits)) + assert not offenders, ( + f"BEAR v2/check_buy_signal 이 실전 경로에 배선됨: {offenders} — " + "mean_reversion_strategy docstring·config 주석·CLAUDE.md §6.1 '백테스트 전용' 서술을 갱신할 것" + ) + # 문서가 사실과 일치하는지 + assert "백테스트 전용" in (mrs.check_bear_buy_signal_v2.__doc__ or "") + + +def test_recommendations_bear_branch_ignores_v2_flag(): + p = os.path.join(BACKEND_DIR, "app", "api", "endpoints", "recommendations.py") + with open(p, encoding="utf-8") as f: + src = f.read() + assert "MEAN_REVERSION_BEAR_V2" not in src + assert "check_bear_buy_signal_v2" not in src diff --git a/backend/tests/test_bugfixes_20260923.py b/backend/tests/test_bugfixes_20260923.py index a37df46..c06f7d4 100644 --- a/backend/tests/test_bugfixes_20260923.py +++ b/backend/tests/test_bugfixes_20260923.py @@ -34,9 +34,13 @@ async def test_dt_full_exit_does_not_zero_swing_shares(): strategy_type="swing", )] q = AsyncMock() - with patch("app.services.db_worker.enqueue_portfolio_upsert", q): + # DB 조회는 목: 실제 AsyncSessionLocal(소켓)을 열지 않는다. None = 조회 실패 → 캐시 수량 기준. + db_q = AsyncMock(return_value=None) + with patch("app.services.db_worker.enqueue_portfolio_upsert", q), \ + patch.object(bridge, "_db_quantity", db_q): await bridge.reflect_sell("064400", 7, 70300.0, remaining_qty=0) + db_q.assert_awaited_once_with("064400") assert portfolio_cache.holdings[0].quantity == 8, "스윙 보유분이 남아야 함" assert q.await_args.args[0]["quantity"] == 8 @@ -138,7 +142,10 @@ async def test_swing_partial_exit_persists_remaining_quantity(): src = inspect.getsource(ses) block = src[src.index("분할 매도 후 remaining_pct 갱신"):] block = block[:block.index("await enqueue_active_trade_upsert(_at_data)")] - assert '"quantity": quantity - sell_qty' in block, "스윙 분할매도에 잔량 갱신 누락" + # [하드닝 R3 2026-10-09] 잔량 = 상한 적용 전 스윙 보유(_swing_basis) − 매도분(_swing_left). + # 동작 검증은 tests/test_swing_hardening_r3_cap_20261009.py. + assert '"quantity": _swing_left' in block, "스윙 분할매도에 잔량 갱신 누락" + assert "_swing_left = max(0, _swing_basis - sell_qty)" in src def test_us_partial_exit_persists_remaining_quantity(): @@ -347,3 +354,15 @@ def test_sync_seeds_active_trade_for_external_positions(): # 큐잉은 commit 성공 후 — 롤백된 Portfolio 에 ActiveTrade 가 생기면 안 된다 assert src.index("await db.commit()") < src.index("_enq_seed_at("), \ "commit 전에 시딩 큐잉 — 롤백 시 유령 ActiveTrade" + + +class _DbBlocked(BaseException): + """AsyncSessionLocal 실사용 차단용(프로덕션 except Exception 에 삼켜지지 않도록 BaseException).""" + + +def test_bridge_db_quantity_uses_blocked_session_guard(): + """회귀: 목 없이 AsyncSessionLocal 을 열면 즉시 실패해야 한다(소켓 차단 증거).""" + import asyncio + with patch("app.core.database.AsyncSessionLocal", side_effect=_DbBlocked("db blocked")): + with pytest.raises(_DbBlocked): + asyncio.run(bridge._db_quantity("064400")) diff --git a/backend/tests/test_collect_krx_data_gaps.py b/backend/tests/test_collect_krx_data_gaps.py new file mode 100644 index 0000000..b2df790 --- /dev/null +++ b/backend/tests/test_collect_krx_data_gaps.py @@ -0,0 +1,346 @@ +"""collect_krx_data: 한쪽 시장 누락일 재수집 / 벤치마크 허용 회귀 테스트 (네트워크·DB 없음).""" +import asyncio +from datetime import date + +import pandas as pd +import pytest + +import collect_krx_data as ck + + +def _df(code="005930", name="삼성전자"): + return pd.DataFrame([{ + "stock_code": code, "stock_name": name, "date": date(2026, 9, 14), + "open": 1, "high": 1, "low": 1, "close": 1, "volume": 1, + "change_rate": 0.0, "market_cap": None, + }]) + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +@pytest.fixture +def no_sleep(monkeypatch): + monkeypatch.setattr(ck, "REQUEST_INTERVAL", 0) + + +def _patch_db(monkeypatch, saved): + class _S: + async def __aenter__(self): + return object() + + async def __aexit__(self, *a): + return False + + async def _up(db, df): + saved.append(len(df)) + return len(df) + + monkeypatch.setattr(ck, "AsyncSessionLocal", lambda: _S()) + monkeypatch.setattr(ck, "upsert_daily_prices", _up) + + +def _patch_fetch(monkeypatch, results): + async def _f(market, bas_dd): + return results[market] + + monkeypatch.setattr(ck, "fetch_daily_prices_v2", _f) + + +def test_kospi_empty_kosdaq_present_is_failure_but_saved(monkeypatch, no_sleep): + saved = [] + _patch_db(monkeypatch, saved) + _patch_fetch(monkeypatch, {"kospi": (pd.DataFrame(), "empty"), "kosdaq": (_df("247540", "x"), "ok")}) + ok, n = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) + assert ok is False and n == 1 and saved == [1] + + +def test_api_error_on_one_market_is_failure(monkeypatch, no_sleep): + _patch_db(monkeypatch, []) + _patch_fetch(monkeypatch, {"kospi": (pd.DataFrame(), "error"), "kosdaq": (_df("247540", "x"), "ok")}) + ok, _ = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) + assert ok is False + + +def test_both_error_is_failure_both_empty_is_holiday(monkeypatch, no_sleep): + _patch_db(monkeypatch, []) + _patch_fetch(monkeypatch, {"kospi": (pd.DataFrame(), "error"), "kosdaq": (pd.DataFrame(), "error")}) + assert _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) == (False, 0) + _patch_fetch(monkeypatch, {"kospi": (pd.DataFrame(), "empty"), "kosdaq": (pd.DataFrame(), "empty")}) + assert _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) == (True, 0) + + +def test_both_ok_is_success(monkeypatch, no_sleep): + _patch_db(monkeypatch, []) + _patch_fetch(monkeypatch, {"kospi": (_df(), "ok"), "kosdaq": (_df("247540", "x"), "ok")}) + ok, n = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) + assert ok is True and n == 2 + + +def test_single_market_mode_not_partial(monkeypatch, no_sleep): + _patch_db(monkeypatch, []) + _patch_fetch(monkeypatch, {"kospi": (_df(), "ok")}) + assert _run(ck.collect_date(1, 1, "20260914", "kospi", asyncio.Semaphore(1)))[0] is True + + +def test_find_incomplete_dates_detects_partial_and_missing(): + counts = {date(2026, 9, 10): 2560, date(2026, 9, 11): 2560, + date(2026, 9, 14): 1672, date(2026, 9, 15): 2560} + got = ck.find_incomplete_dates(counts, date(2026, 9, 10), date(2026, 9, 16)) + assert got == [date(2026, 9, 14), date(2026, 9, 16)] + got = ck.find_incomplete_dates(counts, date(2026, 9, 10), date(2026, 9, 16), + is_holiday=lambda d: d == date(2026, 9, 16)) + assert got == [date(2026, 9, 14)] + assert ck.find_incomplete_dates({}, date(2026, 9, 10), date(2026, 9, 16)) == [] + + +class _Resp: + def __init__(self, items): + self._i = items + + def raise_for_status(self): + pass + + def json(self): + return {"response": {"body": {"totalCount": len(self._i), "items": {"item": self._i}}}} + + +class _Client: + def __init__(self, items): + self._i = items + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def get(self, url, params=None): + return _Resp(self._i) + + +def _item(code, name): + return {"itmsNm": name, "srtnCd": code, "trqu": "10", "clpr": "100", "fltRt": "1", + "mkp": "1", "hipr": "1", "lopr": "1", "mrktTotAmt": "0"} + + +def test_benchmark_etf_whitelist_off_by_default_and_opt_in(monkeypatch): + monkeypatch.setattr(ck.settings, "DATAGOKR_API_KEY", "k", raising=False) + items = [_item("069500", "KODEX 200"), _item("005930", "삼성전자")] + monkeypatch.setattr(ck.httpx, "AsyncClient", lambda **kw: _Client(items)) + monkeypatch.setattr(ck, "_include_benchmarks", False) + df, st = _run(ck.fetch_daily_prices_v2("kospi", "20260914")) + assert st == "ok" and list(df.stock_code) == ["005930"] + monkeypatch.setattr(ck, "_include_benchmarks", True) + df, _ = _run(ck.fetch_daily_prices_v2("kospi", "20260914")) + assert set(df.stock_code) == {"069500", "005930"} + + +def test_http_error_sets_error_status(monkeypatch): + monkeypatch.setattr(ck.settings, "DATAGOKR_API_KEY", "k", raising=False) + + class _Bad(_Client): + async def get(self, url, params=None): + raise RuntimeError("boom") + + monkeypatch.setattr(ck.httpx, "AsyncClient", lambda **kw: _Bad([])) + df, st = _run(ck.fetch_daily_prices_v2("kospi", "20260914")) + assert df.empty and st == "error" + + +# ─── 라운드2: 수정주가 이벤트 / 백필 / 벤치마크 ETF ───────────────── + +def test_is_price_event_split_vs_normal(): + # 5:1 분할: 788000 -> 179200 인데 fltRt 는 +13.7% + assert ck.is_price_event(179200, 788000, 0.1371) is True + assert ck.is_price_event(10300, 10000, 0.03) is False + assert ck.is_price_event(100, 0, 0.0) is False + assert ck.is_price_event(100, 100, None) is False + + +def test_detect_price_events_skips_gap_over_4_days(): + df = pd.DataFrame([ + {"stock_code": "010120", "date": date(2026, 4, 13), "close": 179200, "change_rate": 0.1371}, + {"stock_code": "000670", "date": date(2026, 9, 15), "close": 50500, "change_rate": 0.011}, + ]) + prev = {"010120": (date(2026, 4, 10), 788000), "000670": (date(2026, 9, 1), 38450)} + ev = ck.detect_price_events(df, prev) + assert [e[0] for e in ev] == ["010120"] + + +def test_collect_all_v2_dates_override_runs_only_given_dates(monkeypatch): + seen = [] + + async def _ct(i, total, d, market, sem): + seen.append(d) + return True, 1 + + async def _noop(): + return None + + monkeypatch.setattr(ck, "collect_date", _ct) + monkeypatch.setattr(ck, "create_tables", _noop) + _run(ck.collect_all_v2(dates_override=["20260720", "20260625", "20260914"])) + assert seen == ["20260625", "20260720", "20260914"] + + +def test_benchmark_flag_warns_when_etf_missing(monkeypatch, no_sleep, caplog): + import logging + saved = [] + _patch_db(monkeypatch, saved) + _patch_fetch(monkeypatch, {"kospi": (_df(), "ok"), "kosdaq": (_df("247540", "x"), "ok")}) + + async def _etf(bas_dd): + return pd.DataFrame() + + monkeypatch.setattr(ck, "fetch_benchmark_etf_prices", _etf) + monkeypatch.setattr(ck, "_include_benchmarks", True) + with caplog.at_level(logging.WARNING): + ok, n = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) + assert ok is True + assert any("벤치마크 ETF" in r.getMessage() for r in caplog.records) + + +def test_benchmark_etf_rows_merged_when_present(monkeypatch, no_sleep): + saved = [] + _patch_db(monkeypatch, saved) + _patch_fetch(monkeypatch, {"kospi": (_df(), "ok"), "kosdaq": (_df("247540", "x"), "ok")}) + + async def _etf(bas_dd): + return _df("069500", "KODEX 200") + + monkeypatch.setattr(ck, "fetch_benchmark_etf_prices", _etf) + monkeypatch.setattr(ck, "_include_benchmarks", True) + ok, n = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) + assert ok is True and saved == [3] + + +# ─── 하드닝 라운드1 ──────────────────────────────────────────────── + +def test_normalize_ohlc_fills_zero_with_close_and_keeps_consistency(): + assert ck.normalize_ohlc(0, 0, 0, 600) == (600, 600, 600, 600) + o, h, l, c = ck.normalize_ohlc(100, 90, 95, 120) # high= max(o, c) and l <= min(o, c) and l > 0 + assert ck.normalize_ohlc(100, 110, 90, 105) == (100, 110, 90, 105) + + +def test_fetch_zero_ohlc_row_stored_with_close(monkeypatch): + monkeypatch.setattr(ck.settings, "DATAGOKR_API_KEY", "k", raising=False) + it = _item("089530", "에이티세미콘") + it.update({"mkp": "0", "hipr": "0", "lopr": "0", "clpr": "600"}) + monkeypatch.setattr(ck.httpx, "AsyncClient", lambda **kw: _Client([it])) + df, st = _run(ck.fetch_daily_prices_v2("kosdaq", "20230424")) + r = df.iloc[0] + assert st == "ok" and (r.open, r.high, r.low, r.close) == (600, 600, 600, 600) + + +def test_incomplete_v2_local_baseline_handles_growth_and_whole_day_missing(): + # 종목수 증가 추세: 전역 중앙값 방식이면 과거 정상일이 오탐된다 + counts = {} + d0 = date(2022, 10, 3) + for i in range(0, 400): + k = date.fromordinal(d0.toordinal() + i) + if k.weekday() < 5: + counts[k] = 1900 + i * 2 + miss = date(2022, 11, 3) # 정상 거래일인데 0행 + half = date(2022, 11, 2) # 한쪽 시장 누락 + counts.pop(miss, None) + counts[half] = 907 + got = ck.find_incomplete_dates_v2(counts, date(2022, 10, 3), date(2023, 10, 1)) + assert miss in got and half in got + assert len(got) == 2 + assert ck.find_incomplete_dates_v2({}, date(2022, 10, 3), date(2022, 10, 7)) == [] + + +def test_default_lookback_covers_more_than_three_weeks(): + assert ck.INCOMPLETE_LOOKBACK_DAYS > 21 + + +# ─── 라운드 5: 거래정지 봉 보존 / 유니버스 ETF 수집 ─────────────────── + +class _Resp: + def __init__(self, items): + self._items = items + + def raise_for_status(self): + pass + + def json(self): + return {"response": {"body": {"totalCount": len(self._items), "items": {"item": self._items}}}} + + +def _patch_http(monkeypatch, items): + class _C: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def get(self, url, params=None): + return _Resp(items) + + monkeypatch.setattr(ck.httpx, "AsyncClient", _C) + monkeypatch.setattr(ck.settings, "DATAGOKR_API_KEY", "k", raising=False) + monkeypatch.setattr(ck, "REQUEST_INTERVAL", 0) + + +def test_halted_bar_kept_flat_with_zero_volume(monkeypatch): + _patch_http(monkeypatch, [ + {"itmsNm": "정지종목", "srtnCd": "900120", "trqu": "0", "clpr": "1000", + "mkp": "0", "hipr": "0", "lopr": "0", "fltRt": "3.5"}, + {"itmsNm": "정상", "srtnCd": "005930", "trqu": "10", "clpr": "2000", + "mkp": "1900", "hipr": "2100", "lopr": "1800", "fltRt": "1.0"}, + ]) + df, st = _run(ck.fetch_daily_prices_v2("kospi", "20260914")) + assert st == "ok" and len(df) == 2 + h = df[df.stock_code == "900120"].iloc[0] + assert h.volume == 0 and h.open == h.high == h.low == h.close == 1000 + assert h.change_rate == 0.0 + + +def test_zero_close_still_skipped(monkeypatch): + _patch_http(monkeypatch, [ + {"itmsNm": "x", "srtnCd": "000001", "trqu": "0", "clpr": "0"}, + ]) + df, _ = _run(ck.fetch_daily_prices_v2("kospi", "20260914")) + assert df.empty + + +def test_halted_aware_ohlc_pure(): + assert ck.halted_aware_ohlc(0, 0, 0, 500, 0) == (500, 500, 500, 500) + assert ck.halted_aware_ohlc(0, 0, 0, 500, 7) == ck.normalize_ohlc(0, 0, 0, 500) + + +def test_etf_fetch_limited_to_requested_codes(monkeypatch): + _patch_http(monkeypatch, [ + {"itmsNm": "PLUS K방산", "srtnCd": "449450", "trqu": "100", "clpr": "30000", + "mkp": "29900", "hipr": "30100", "lopr": "29800", "fltRt": "0.5"}, + {"itmsNm": "다른ETF", "srtnCd": "111111", "trqu": "100", "clpr": "1000"}, + ]) + df = _run(ck.fetch_etf_prices_v2("20260914", {"449450"})) + assert list(df.stock_code) == ["449450"] + assert _run(ck.fetch_etf_prices_v2("20260914", set())).empty + + +def test_extra_etf_codes_off_by_default_and_merged_when_set(monkeypatch, no_sleep): + saved = [] + _patch_db(monkeypatch, saved) + _patch_fetch(monkeypatch, {"kospi": (_df(), "ok"), "kosdaq": (_df("247540", "x"), "ok")}) + calls = [] + + async def _etf(bas_dd, codes): + calls.append(set(codes)) + return _df("449450", "PLUS K방산") + + monkeypatch.setattr(ck, "fetch_etf_prices_v2", _etf) + monkeypatch.setattr(ck, "_extra_etf_codes", frozenset()) + _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) + assert calls == [] and saved == [2] + monkeypatch.setattr(ck, "_extra_etf_codes", frozenset({"449450"})) + ok, n = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) + assert calls == [{"449450"}] and n == 3 and ok is True diff --git a/backend/tests/test_correlation_filter.py b/backend/tests/test_correlation_filter.py index 76ddb1a..8b97897 100644 --- a/backend/tests/test_correlation_filter.py +++ b/backend/tests/test_correlation_filter.py @@ -89,9 +89,20 @@ async def test_get_stock_sector_not_found(): _sector_cache.clear() - # 캐시와 DB에 없는 종목 - sector = await get_stock_sector("999999") + # 캐시와 DB에 없는 종목. 실제 AsyncSessionLocal(소켓)은 BaseException 으로 차단하고, + # db 인자로 빈 결과를 주는 목 세션을 사용한다. + class _DbBlocked(BaseException): + pass + + empty = Mock() + empty.scalars.return_value.first.return_value = None + session = AsyncMock() + session.execute.return_value = empty + with patch("app.services.correlation_filter.AsyncSessionLocal", + side_effect=_DbBlocked("real DB session blocked")): + sector = await get_stock_sector("999999", db=session) assert sector is None + session.execute.assert_awaited_once() # ─ 섹터 집중도 필터 테스트 ──────────────────────────────────────────────────── @@ -238,7 +249,7 @@ async def test_calculate_portfolio_correlation_no_data(): h.quantity = 100 holdings.append(h) - with patch("app.services.correlation_filter._get_daily_prices", new_callable=AsyncMock) as mock_prices: + with patch("app.services.correlation_filter._get_daily_prices_v2", new_callable=AsyncMock) as mock_prices: mock_prices.return_value = None is_valid, reason, max_corr = await calculate_portfolio_correlation( @@ -264,7 +275,7 @@ async def test_validate_correlation_filter_all_pass(sample_portfolio_holdings): _sector_cache["005380"] = ("자동차", datetime.now().timestamp()) _sector_cache["123456"] = ("금융", datetime.now().timestamp()) - with patch("app.services.correlation_filter._get_daily_prices", new_callable=AsyncMock) as mock_prices: + with patch("app.services.correlation_filter._get_daily_prices_v2", new_callable=AsyncMock) as mock_prices: # mock_prices는 신규 종목과 기존 포트폴리오 종목 모두에 호출됨. # return_value를 단일 값으로 설정하면 모두 동일한 데이터를 받아 상관계수 1.0이 됨. # side_effect로 신규 종목("123456")은 지그재그, 기존 종목은 상승 패턴을 반환. @@ -277,9 +288,11 @@ async def test_validate_correlation_filter_all_pass(sample_portfolio_holdings): async def _side_effect(stock_code, lookback_days=60): _call_count[0] += 1 + from datetime import date, timedelta as _td + _d0 = date(2026, 1, 1) if stock_code == "123456": - return zigzag_prices - return rising_prices # 포트폴리오 종목은 상승 패턴 → 지그재그와 낮은 상관계수 + return [(_d0 + _td(days=i), p) for i, p in enumerate(zigzag_prices)] + return [(_d0 + _td(days=i), p) for i, p in enumerate(rising_prices)] # 포트폴리오 종목은 상승 패턴 → 지그재그와 낮은 상관계수 mock_prices.side_effect = _side_effect @@ -343,3 +356,279 @@ async def test_sector_cache_expiration(): if __name__ == "__main__": pytest.main([__file__, "-v"]) + + + +# ─ [하드닝 R1] 날짜 정렬·fail 처리·섹터표 회귀 ───────────────────────────────── + +def _series(start, closes): + from datetime import date, timedelta as _td + d0 = date(2026, 1, 1) + _td(days=start) + return [(d0 + _td(days=i), c) for i, c in enumerate(closes)] + + +def _holding(code): + h = Mock(spec=Portfolio) + h.stock_code = code + h.quantity = 10 + return h + + +@pytest.mark.asyncio +async def test_correlation_aligns_by_date_not_tail(): + """길이가 달라도 날짜가 맞는 구간끼리만 상관을 계산한다(꼬리 정렬 금지).""" + import random + random.seed(7) + base = [100.0] + for _ in range(79): + base.append(base[-1] * (1 + random.uniform(-0.03, 0.03))) + new = _series(0, base) # 80일 + port = _series(20, base[20:]) # 뒤 60일, 동일 가격 → 공통 구간 상관 1.0 + # 꼬리 정렬이면 new[-60:] 와 port 가 같아 우연히 맞으므로, 앞쪽 결손 시나리오로 검증: + port_gap = _series(0, base[:60]) # 앞 60일. 꼬리 정렬 시 new[-60:](20~79) vs port(0~59) 불일치 + async def fake(code, lookback_days=60): + return new if code == "NEW" else port_gap + with patch("app.services.correlation_filter._get_daily_prices_v2", side_effect=fake): + ok, reason, corr = await calculate_portfolio_correlation("NEW", [_holding("P")], 0.7, 60) + assert corr is not None and corr > 0.99 # 날짜 정렬 → 동일 가격 → 1.0 + assert ok is False and "high_correlation" in reason + + +@pytest.mark.asyncio +async def test_correlation_few_common_dates_excluded(): + import app.services.correlation_filter as cf + before = cf.get_correlation_event_counts().get("common_dates_insufficient", 0) + a = _series(0, [100 + i for i in range(30)]) + b = _series(25, [100 + i for i in range(30)]) # 공통 5일 + async def fake(code, lookback_days=60): + return a if code == "NEW" else b + with patch("app.services.correlation_filter._get_daily_prices_v2", side_effect=fake): + ok, reason, corr = await calculate_portfolio_correlation("NEW", [_holding("P")], 0.7, 60) + assert corr is None and reason == "correlation_calc_failed" + assert cf.get_correlation_event_counts()["common_dates_insufficient"] == before + 1 + + +@pytest.mark.asyncio +async def test_correlation_exception_propagates_for_fail_closed(): + """내부 예외를 삼키지 않는다 — 호출부 fail-closed 가 작동해야 한다.""" + with patch("app.services.correlation_filter._get_daily_prices_v2", + new_callable=AsyncMock, side_effect=RuntimeError("boom")): + with pytest.raises(RuntimeError): + await calculate_portfolio_correlation("NEW", [_holding("P")], 0.7, 60) + + +def test_static_sector_table_misclassifications_removed(): + from app.services.correlation_filter import _STOCK_CODE_SECTOR_MAP + for code in ("012450", "003550", "091990"): + assert code not in _STOCK_CODE_SECTOR_MAP + + +@pytest.mark.asyncio +async def test_kis_fallback_drops_today_intraday_bar(): + """KIS 폴백 경로는 당일(KST) 장중 미완성 봉을 제외한다.""" + from zoneinfo import ZoneInfo + from app.services import correlation_filter as cf + today = datetime.now(ZoneInfo("Asia/Seoul")).date() + d1 = today - timedelta(days=1) + output = [ # KIS 는 최신순 + {"stck_bsop_date": today.strftime("%Y%m%d"), "stck_clpr": "105"}, + {"stck_bsop_date": d1.strftime("%Y%m%d"), "stck_clpr": "100"}, + ] + + class _Boom: + async def __aenter__(self): + raise RuntimeError("db blocked") + + async def __aexit__(self, *a): + return False + + with patch.object(cf, "AsyncSessionLocal", lambda: _Boom()), \ + patch.object(cf.korea_investment_api, "get_daily_chart", + new=AsyncMock(return_value={"output2": output})): + rows = await cf._get_daily_prices_v2("069500", 60) + assert rows == [(d1, 100.0)] + + +# ─ [하드닝 R4] 보유 종목 추가매수 시 자기 자신 제외 ───────────────────────────── + +@pytest.mark.asyncio +async def test_correlation_excludes_self_holding_on_add_buy(): + """이미 보유한 종목 재추천(추가매수)은 자기 상관 1.0 으로 거부되지 않는다.""" + base = _series(0, [100 + (i % 7) + i * 0.1 for i in range(60)]) + + async def fake(code, lookback_days=60): + if code != "A": + raise AssertionError(f"자기 외 종목 조회 불가: {code}") + return base + with patch("app.services.correlation_filter._get_daily_prices_v2", side_effect=fake): + ok, reason, corr = await calculate_portfolio_correlation("A", [_holding("A")], 0.7, 60) + assert ok is True and reason == "portfolio_empty" and corr is None + + +@pytest.mark.asyncio +async def test_correlation_self_excluded_but_others_still_compared(): + import random + random.seed(3) + px = [100.0] + for _ in range(59): + px.append(px[-1] * (1 + random.uniform(-0.03, 0.03))) + s = _series(0, px) + + async def fake(code, lookback_days=60): + return s + with patch("app.services.correlation_filter._get_daily_prices_v2", side_effect=fake): + ok, reason, _ = await calculate_portfolio_correlation( + "A", [_holding("A"), _holding("B")], 0.7, 60) + assert ok is False and "high_correlation_with_B" in reason + + +@pytest.mark.asyncio +async def test_sector_count_excludes_self_on_add_buy(): + from app.services.correlation_filter import _sector_cache + _sector_cache.clear() + now = datetime.now().timestamp() + for c in ("A", "B", "C"): + _sector_cache[c] = ("IT", now) + # A 포함 IT 3종목 보유 → A 추가매수는 자기 제외 후 2종목이므로 통과 + ok, reason = await check_sector_concentration( + "A", [_holding("A"), _holding("B"), _holding("C")], max_per_sector=3) + assert ok is True and reason is None + # 신규 D(IT)는 3종목 → 여전히 거부 + _sector_cache["D"] = ("IT", now) + ok, reason = await check_sector_concentration( + "D", [_holding("A"), _holding("B"), _holding("C")], max_per_sector=3) + assert ok is False and "sector_limit" in reason + + +@pytest.mark.asyncio +async def test_correlation_zero_close_on_one_side_still_compared(): + """한쪽에만 0 종가가 있어도 해당 날짜를 양쪽에서 제외하고 비교를 수행한다(조용한 생략 금지).""" + import random + random.seed(11) + base = [100.0] + for _ in range(59): + base.append(base[-1] * (1 + random.uniform(-0.03, 0.03))) + new = _series(0, base) + holding_closes = list(base) + holding_closes[30] = 0.0 # 보유종목 한쪽에만 0 행 + port = _series(0, holding_closes) + async def fake(code, lookback_days=60): + return new if code == "NEW" else port + with patch("app.services.correlation_filter._get_daily_prices_v2", side_effect=fake): + ok, reason, corr = await calculate_portfolio_correlation("NEW", [_holding("P")], 0.7, 60) + assert reason != "correlation_calc_failed" + assert corr is not None and corr > 0.99 + assert ok is False + + +# ─ [하드닝 R5] 보유종목 시계열 캐시 / 일괄 선적재 / 선택적 fail-closed ─────────── + +@pytest.mark.asyncio +async def test_r5_holding_series_cached_across_candidates(): + """같은 보유종목 시계열은 후보가 달라도 TTL 내 재조회하지 않는다.""" + import app.services.correlation_filter as cf + cf.clear_price_cache_v3() + calls = [] + series = _series(0, [100 + (i % 5) + i * 0.1 for i in range(60)]) + + async def fake(code, lookback_days=60): + calls.append(code) + return series + with patch("app.services.correlation_filter._get_daily_prices_v2", side_effect=fake): + await calculate_portfolio_correlation("N1", [_holding("H1")], 0.7, 60) + await calculate_portfolio_correlation("N2", [_holding("H1")], 0.7, 60) + assert calls.count("H1") == 1 + cf.clear_price_cache_v3() + + +@pytest.mark.asyncio +async def test_r5_prefetch_single_session_in_query(): + """선적재는 보유 N종목에 대해 세션을 1번만 연다.""" + import app.services.correlation_filter as cf + cf.clear_price_cache_v3() + opened = [] + start = date.today() - timedelta(days=70) + rows = [(c, start + timedelta(days=i), 100.0 + i) for c in ("A", "B") for i in range(25)] + + class _Res: + def fetchall(self): + return rows + + class _Sess: + async def __aenter__(self): + opened.append(1) + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, q): + return _Res() + + with patch.object(cf, "AsyncSessionLocal", lambda: _Sess()): + await cf._prefetch_daily_prices_v3(["A", "B"], 60) + assert len(opened) == 1 + assert len(cf._price_cache_get("A", 60)) == 25 and len(cf._price_cache_get("B", 60)) == 25 + cf.clear_price_cache_v3() + + +@pytest.mark.asyncio +async def test_r5_fail_closed_option_blocks_when_holding_data_missing(): + import app.services.correlation_filter as cf + cf.clear_price_cache_v3() + series = _series(0, [100 + (i % 5) + i * 0.1 for i in range(60)]) + + async def fake(code, lookback_days=60): + return series if code == "NEW" else None + with patch("app.services.correlation_filter._get_daily_prices_v2", side_effect=fake): + with patch.object(cf, "settings", Mock(CORRELATION_FAIL_CLOSED_ON_MISSING_DATA=True)): + ok, reason, _ = await calculate_portfolio_correlation("NEW", [_holding("P")], 0.7, 60) + assert ok is False and reason.startswith("holding_data_unavailable") + with patch.object(cf, "settings", Mock(CORRELATION_FAIL_CLOSED_ON_MISSING_DATA=False)): + ok2, reason2, _ = await calculate_portfolio_correlation("NEW", [_holding("P")], 0.7, 60) + assert ok2 is True and reason2 == "correlation_calc_failed" + cf.clear_price_cache_v3() + + +def test_r8_config_declares_correlation_keys(): + from app.core.config import Settings + f = Settings.model_fields + for k in ("CORRELATION_FAIL_CLOSED_ON_MISSING_DATA", "CORRELATION_FAIL_CLOSED_ON_CANDIDATE_DATA", + "MAX_STOCKS_PER_SECTOR", "CORRELATION_THRESHOLD"): + assert k in f + assert f["CORRELATION_FAIL_CLOSED_ON_MISSING_DATA"].default is False + assert f["CORRELATION_FAIL_CLOSED_ON_CANDIDATE_DATA"].default is False + assert f["MAX_STOCKS_PER_SECTOR"].default == 3 and f["CORRELATION_THRESHOLD"].default == 0.7 + + +@pytest.mark.asyncio +async def test_r8_candidate_data_insufficient_fail_closed_switch(): + import app.services.correlation_filter as cf + cf.clear_price_cache_v3() + + async def fake(code, lookback_days=60): + return None + with patch("app.services.correlation_filter._get_daily_prices_v2", side_effect=fake): + with patch.object(cf, "settings", Mock(CORRELATION_FAIL_CLOSED_ON_CANDIDATE_DATA=True)): + ok, reason, _ = await calculate_portfolio_correlation("NEW", [_holding("P")], 0.7, 60) + assert ok is False and reason == "correlation_data_unavailable" + with patch.object(cf, "settings", Mock(CORRELATION_FAIL_CLOSED_ON_CANDIDATE_DATA=False)): + ok2, reason2, _ = await calculate_portfolio_correlation("NEW", [_holding("P")], 0.7, 60) + assert ok2 is True and reason2 == "insufficient_data" + cf.clear_price_cache_v3() + + +@pytest.mark.asyncio +async def test_r8_calc_failed_fail_closed_switch(): + import app.services.correlation_filter as cf + cf.clear_price_cache_v3() + series = _series(0, [100 + (i % 5) + i * 0.1 for i in range(60)]) + + async def fake(code, lookback_days=60): + return series if code == "NEW" else None + with patch("app.services.correlation_filter._get_daily_prices_v2", side_effect=fake): + with patch.object(cf, "settings", Mock(CORRELATION_FAIL_CLOSED_ON_CANDIDATE_DATA=True, + CORRELATION_FAIL_CLOSED_ON_MISSING_DATA=False)): + ok, reason, _ = await calculate_portfolio_correlation("NEW", [_holding("P")], 0.7, 60) + assert ok is False and reason == "correlation_data_unavailable" + cf.clear_price_cache_v3() diff --git a/backend/tests/test_daytrading_watchlist_size_20260930.py b/backend/tests/test_daytrading_watchlist_size_20260930.py index 240eb59..5971af5 100644 --- a/backend/tests/test_daytrading_watchlist_size_20260930.py +++ b/backend/tests/test_daytrading_watchlist_size_20260930.py @@ -264,9 +264,18 @@ def test_budget_clamp_depends_only_on_its_arguments(): def test_portfolio_holdings_cap_is_unrelated_to_daytrading_watchlist(): - """MAX_HOLDINGS_COUNT(포트폴리오 전체 보유 한도)는 DT 진입 경로에 없다.""" + """MAX_HOLDINGS_COUNT 는 감시목록 크기와 무관하며, DT 적용은 플래그(기본 OFF) 뒤에만 있다.""" import app.services.daytrading.entry_service as mod - assert "MAX_HOLDINGS_COUNT" not in inspect.getsource(mod) + assert not getattr(settings, "DAY_TRADE_HOLDINGS_CAP_ENABLED", False) + assert not getattr(settings, "DAY_TRADE_SECTOR_CAP_ENABLED", False) + es.set_watchlist([{"code": f"{i:06d}", "name": "x"} for i in range(50)]) + try: + # 플래그 OFF 면 감시목록이 커도 보유수 상한으로 차단되지 않는다(현행 동작). + assert mod._portfolio_cap_block_v2("005930") is None + finally: + es.set_watchlist([]) + src = inspect.getsource(mod._portfolio_cap_block_v2) + assert src.index("holdings_on and") < src.index("MAX_HOLDINGS_COUNT") # ── 3. 스캔 예산 불변식 ─────────────────────────────────────────────────────── diff --git a/backend/tests/test_db_worker_backlog_success_path_20261009.py b/backend/tests/test_db_worker_backlog_success_path_20261009.py new file mode 100644 index 0000000..0fa317f --- /dev/null +++ b/backend/tests/test_db_worker_backlog_success_path_20261009.py @@ -0,0 +1,46 @@ +"""성공 경로에서도 큐 적체 경보가 평가되는지 (라운드 2).""" +import asyncio + +from app.services import db_worker as w + + +def _queue_with(n): + q = asyncio.Queue() + for i in range(n): + q.put_nowait({"type": "x", "i": i}) + return q + + +def _patch(monkeypatch): + calls = [] + + def fake(key, cat, msg, interval=0): + calls.append((key, msg)) + return True + + monkeypatch.setattr(w, "_alert_throttled_v2", fake) + monkeypatch.setitem(w._depth_check_state, "last", 0.0) + return calls + + +def test_alerts_when_deep_even_without_failure(monkeypatch): + calls = _patch(monkeypatch) + q = _queue_with(w.WORKER_QUEUE_ALERT_DEPTH) + assert w._check_queue_backlog_v2(q, 7.0, now=1000.0) is True + assert calls and calls[0][0] == "db_worker:queue_depth" + + +def test_no_alert_below_threshold(monkeypatch): + calls = _patch(monkeypatch) + q = _queue_with(w.WORKER_QUEUE_ALERT_DEPTH - 1) + assert w._check_queue_backlog_v2(q, 0.1, now=1000.0) is False + assert not calls + + +def test_checked_at_most_once_per_interval(monkeypatch): + calls = _patch(monkeypatch) + q = _queue_with(w.WORKER_QUEUE_ALERT_DEPTH) + assert w._check_queue_backlog_v2(q, now=1000.0) is True + assert w._check_queue_backlog_v2(q, now=1000.0 + w.QUEUE_DEPTH_CHECK_INTERVAL_SEC - 1) is False + assert w._check_queue_backlog_v2(q, now=1000.0 + w.QUEUE_DEPTH_CHECK_INTERVAL_SEC + 1) is True + assert len(calls) == 2 diff --git a/backend/tests/test_db_worker_hardening_20261009.py b/backend/tests/test_db_worker_hardening_20261009.py new file mode 100644 index 0000000..f6f52be --- /dev/null +++ b/backend/tests/test_db_worker_hardening_20261009.py @@ -0,0 +1,205 @@ +"""2026-10-09 db_worker 하드닝 회귀: DLQ 경보, 비차단 백오프/회로, 추가매수 remaining_pct 불변식.""" +import asyncio +from types import SimpleNamespace + +import pytest + +import app.services.db_worker as dbw +from app.core import log_throttle + + +def _run(coro): + return asyncio.run(coro) + + +@pytest.fixture(autouse=True) +def _iso(monkeypatch, tmp_path): + log_throttle.reset_for_tests() + monkeypatch.setattr(dbw, "DLQ_DIR", tmp_path / "dlq") + monkeypatch.setattr(dbw, "_db_task_queue", None) + yield + log_throttle.reset_for_tests() + + +# ── 1. DLQ 텔레그램 ────────────────────────────────────────────────────────── +def test_save_to_dlq_sends_throttled_telegram(monkeypatch): + sent = [] + + async def fake_send(category, message): + sent.append((category, message)) + + monkeypatch.setattr(dbw, "_send_alert_impl", fake_send) + + async def go(): + await dbw._save_to_dlq({"type": "close_active_trade", "data": {}}, "boom") + await dbw._save_to_dlq({"type": "close_active_trade", "data": {}}, "boom") + await asyncio.sleep(0.05) + + _run(go()) + assert len(sent) == 1 and sent[0][0] == "DLQ" # throttle + assert len(list(dbw.DLQ_DIR.glob("*.json"))) == 2 + + +def test_dlq_write_failure_alerts(monkeypatch): + sent = [] + + async def fake_send(category, message): + sent.append(message) + + monkeypatch.setattr(dbw, "_send_alert_impl", fake_send) + + def bad(*a, **k): + raise OSError("disk full") + + monkeypatch.setattr(dbw, "DLQ_DIR", type("P", (), {"mkdir": bad, "__truediv__": bad})()) + + async def go(): + await dbw._save_to_dlq({"type": "upsert_stock_ban", "data": {}}, "x") + await asyncio.sleep(0.05) + + _run(go()) + assert len(sent) == 1 and "유실" in sent[0] + + +# ── 2. 비차단 백오프 ───────────────────────────────────────────────────────── +def test_failed_task_does_not_block_following_tasks(monkeypatch): + done = [] + + async def fake_exec(task): + if task["type"] == "bad": + raise RuntimeError("db down") + done.append(task["type"]) + + monkeypatch.setattr(dbw, "_execute_db_task_impl", fake_exec) + + async def fake_dlq(task, err): + return None + + monkeypatch.setattr(dbw, "_save_to_dlq", fake_dlq) + monkeypatch.setattr(dbw, "CIRCUIT_OPEN_FAILURES", 999) + + async def go(): + q = dbw.get_db_task_queue() + w = asyncio.ensure_future(dbw.db_writer_worker()) + await q.put({"type": "bad", "data": {}}) + await q.put({"type": "good1", "data": {}}) + await q.put({"type": "good2", "data": {}}) + await asyncio.sleep(0.2) # 백오프 최소 1초 — 그 안에 정상 태스크는 처리돼야 한다 + snapshot = list(done) + w.cancel() + try: + await w + except asyncio.CancelledError: + pass + return snapshot + + assert _run(go()) == ["good1", "good2"] + + +def test_circuit_open_keeps_retry_count(monkeypatch): + async def fake_exec(task): + raise RuntimeError("db down") + + monkeypatch.setattr(dbw, "_execute_db_task_impl", fake_exec) + monkeypatch.setattr(dbw, "CIRCUIT_OPEN_FAILURES", 2) + monkeypatch.setattr(dbw, "CIRCUIT_RETRY_DELAY_SEC", 0.01) + dropped = [] + + async def fake_dlq(task, err): + dropped.append(task) + + monkeypatch.setattr(dbw, "_save_to_dlq", fake_dlq) + + async def fake_one(task, started): # 종료 drain 은 별개 — 여기서는 워커 루프 중 DLQ 만 본다 + return None + + monkeypatch.setattr(dbw, "_drain_one_impl", fake_one) + + async def go(): + q = dbw.get_db_task_queue() + w = asyncio.ensure_future(dbw.db_writer_worker()) + t = {"type": "x", "data": {}} + await q.put(t) + await q.put({"type": "y", "data": {}}) # 연속 실패 2회 → 회로 열림 + await asyncio.sleep(0.3) + rc = t.get("_retry_count", 0) + state = dbw.get_worker_health_v2() + w.cancel() + try: + await w + except asyncio.CancelledError: + pass + return rc, state + + rc, state = _run(go()) + assert dropped == [] # 장애 구간에서 DLQ 로 조기 유실되지 않는다 + assert rc <= 1 # 회로 열림 전 1회만 소모 + assert state["circuit_open"] is True + + +def test_cancel_during_backoff_drains_task(monkeypatch): + drained = [] + + async def fake_exec(task): + raise RuntimeError("once") + + monkeypatch.setattr(dbw, "_execute_db_task_impl", fake_exec) + monkeypatch.setattr(dbw, "CIRCUIT_OPEN_FAILURES", 999) + + async def fake_one(task, started): + drained.append("drain:" + task["type"]) + + monkeypatch.setattr(dbw, "_drain_one_impl", fake_one) + + async def go(): + q = dbw.get_db_task_queue() + w = asyncio.ensure_future(dbw.db_writer_worker()) + await q.put({"type": "t", "data": {}}) + await asyncio.sleep(0.1) # 백오프(1s) 대기 중 + w.cancel() + try: + await w + except asyncio.CancelledError: + pass + + _run(go()) + assert drained == ["drain:t"] + + +# ── 3. 추가매수 remaining_pct 불변식 ───────────────────────────────────────── +def _rec(**kw): + base = dict(id=7, quantity=100, entry_price=10000.0, highest_since_entry=10500.0, remaining_pct=0.5) + base.update(kw) + return SimpleNamespace(**base) + + +def test_accumulate_after_partial_exit_keeps_held_invariant(): + r = _rec() # 원본 100, 실보유 50 + data = {"accumulate_entry": True, "quantity": 50, "entry_price": 8000.0} + dbw._apply_entry_price_adjustments_v2(r, data) + assert r.quantity == 150 + assert r.quantity * r.remaining_pct == pytest.approx(100.0, abs=0.01) # 실보유 100 + r2 = _rec() # 재실행(종료 drain)도 같은 값 + dbw._apply_entry_price_adjustments_v2(r2, data) + assert (r2.quantity, r2.remaining_pct) == (r.quantity, r.remaining_pct) + + +def test_accumulate_without_prior_exit_leaves_remaining_pct(): + r = _rec(remaining_pct=1.0) + dbw._apply_entry_price_adjustments_v2(r, {"accumulate_entry": True, "quantity": 10, "entry_price": 9000.0}) + assert r.remaining_pct == 1.0 and r.quantity == 110 + + +def test_accumulate_ignores_caller_remaining_pct(): + r = _rec() + skip = dbw._apply_entry_price_adjustments_v2( + r, {"accumulate_entry": True, "quantity": 50, "entry_price": 8000.0, "remaining_pct": 1.0}) + assert "remaining_pct" in skip + + +def test_fill_adjust_uses_held_quantity_as_denominator(): + # 실보유 100(원본150×2/3), 추가매수 50주가 주문가보다 100원 싸게 체결 + r = _rec(quantity=150, remaining_pct=100 / 150, entry_price=9000.0) + d = {"entry_fill_adjust": {"order_price": 8000.0, "filled_price": 7900.0, "filled_qty": 50}} + dbw._apply_entry_price_adjustments_v2(r, d) + assert r.entry_price == pytest.approx(9000.0 - 100 * 50 / 100, abs=0.01) diff --git a/backend/tests/test_db_worker_stock_ban_tz_20261010.py b/backend/tests/test_db_worker_stock_ban_tz_20261010.py new file mode 100644 index 0000000..892f4ff --- /dev/null +++ b/backend/tests/test_db_worker_stock_ban_tz_20261010.py @@ -0,0 +1,37 @@ +"""stock_bans.ban_until 은 naive-UTC 규약(loss_tracker 의 to_aware_utc 와 쌍)을 고정한다.""" +import asyncio +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock + +from app.services import db_worker + + +def _session(existing=None): + s = MagicMock() + res = MagicMock() + res.scalar_one_or_none.return_value = existing + s.execute = AsyncMock(return_value=res) + s.commit = AsyncMock() + s.add = MagicMock() + return s + + +def test_stock_ban_saved_as_naive_utc_from_kst_aware(): + kst = timezone(timedelta(hours=9)) + until = datetime(2026, 11, 19, 10, 5, 21, tzinfo=kst) + s = _session() + asyncio.run(db_worker._handle_upsert_stock_ban( + s, {"stock_code": "006260", "consecutive_losses": 3, "ban_until": until})) + rec = s.add.call_args[0][0] + assert rec.ban_until == datetime(2026, 11, 19, 1, 5, 21) + assert rec.ban_until.tzinfo is None + + +def test_stock_ban_roundtrip_with_loss_tracker_reader(): + from app.core.dt_utils import to_aware_utc + until = datetime.now(timezone.utc) + timedelta(days=42) + s = _session() + asyncio.run(db_worker._handle_upsert_stock_ban( + s, {"stock_code": "005930", "consecutive_losses": 3, "ban_until": until})) + stored = s.add.call_args[0][0].ban_until + assert abs((to_aware_utc(stored) - until).total_seconds()) < 1 diff --git a/backend/tests/test_db_worker_write_order_20261009.py b/backend/tests/test_db_worker_write_order_20261009.py new file mode 100644 index 0000000..2500009 --- /dev/null +++ b/backend/tests/test_db_worker_write_order_20261009.py @@ -0,0 +1,113 @@ +"""재시도 대기 중인 쓰기가 같은 종목의 뒤 쓰기보다 늦게 실행되지 않는지 (라운드 3).""" +import asyncio + +import pytest + +from app.services import db_worker as dbw + + +@pytest.fixture(autouse=True) +def _iso(monkeypatch, tmp_path): + monkeypatch.setattr(dbw, "DLQ_DIR", tmp_path / "dlq") + monkeypatch.setattr(dbw, "_db_task_queue", None) + yield + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def test_keys_cover_stock_and_order(): + k = dbw._task_order_keys_v2({"data": {"stock_code": "005930", "order_id": "A1"}}) + assert ("s", "005930") in k and ("o", "A1") in k + assert dbw._task_order_keys_v2({"data": {}}) == frozenset() + + +def test_gate_holds_and_releases_in_order(): + g = dbw._OrderGateV2() + a = {"type": "upsert_active_trade", "data": {"stock_code": "X"}} + b = {"type": "close_active_trade", "data": {"stock_code": "X"}} + c = {"type": "close_active_trade", "data": {"stock_code": "Y"}} + g.defer(a) + assert not g.should_hold(a) and g.should_hold(b) and not g.should_hold(c) + g.hold(b) + assert g.resolve(a) == [b] + assert not g.should_hold(b) + + +def test_failed_upsert_runs_before_later_close(monkeypatch): + order = [] + fails = {"n": 1} + + async def fake_exec(task): + if task["type"] == "upsert_active_trade" and fails["n"] > 0: + fails["n"] -= 1 + raise RuntimeError("db down") + order.append(task["type"]) + + async def fake_dlq(task, err): + return None + + monkeypatch.setattr(dbw, "_execute_db_task_impl", fake_exec) + monkeypatch.setattr(dbw, "_save_to_dlq", fake_dlq) + monkeypatch.setattr(dbw, "CIRCUIT_OPEN_FAILURES", 999) + + async def go(): + q = dbw.get_db_task_queue() + w = asyncio.ensure_future(dbw.db_writer_worker()) + await q.put({"type": "upsert_active_trade", "data": {"stock_code": "X"}}) + await q.put({"type": "close_active_trade", "data": {"stock_code": "X"}}) + await q.put({"type": "other", "data": {"stock_code": "Y"}}) + await asyncio.sleep(1.8) + snap = list(order) + w.cancel() + try: + await w + except asyncio.CancelledError: + pass + return snap + + snap = _run(go()) + assert snap.index("upsert_active_trade") < snap.index("close_active_trade") + assert snap[0] == "other" # 다른 종목은 막히지 않는다 + + +def test_join_not_released_while_retry_pending_and_held(monkeypatch): + """실패 후 재시도 대기·보류 중에는 queue.join() 이 풀리지 않고, 모두 끝나야 풀린다.""" + done = [] + fails = {"n": 1} + + async def fake_exec(task): + if task["type"] == "upsert_active_trade" and fails["n"] > 0: + fails["n"] -= 1 + raise RuntimeError("db down") + done.append(task["type"]) + + async def fake_dlq(task, err): + return None + + monkeypatch.setattr(dbw, "_execute_db_task_impl", fake_exec) + monkeypatch.setattr(dbw, "_save_to_dlq", fake_dlq) + monkeypatch.setattr(dbw, "CIRCUIT_OPEN_FAILURES", 999) + + async def go(): + q = dbw.get_db_task_queue() + w = asyncio.ensure_future(dbw.db_writer_worker()) + await q.put({"type": "upsert_active_trade", "data": {"stock_code": "X"}}) + await q.put({"type": "close_active_trade", "data": {"stock_code": "X"}}) + await asyncio.sleep(0.3) # 재시도 백오프(1s) 중 + mid_done = list(done) + mid_unfinished = q._unfinished_tasks + await asyncio.wait_for(q.join(), timeout=5) + end = list(done) + w.cancel() + try: + await w + except asyncio.CancelledError: + pass + return mid_done, mid_unfinished, end + + mid_done, mid_unfinished, end = _run(go()) + assert "close_active_trade" not in mid_done + assert mid_unfinished >= 2 + assert end == ["upsert_active_trade", "close_active_trade"] diff --git a/backend/tests/test_display_data_fixes_20261008.py b/backend/tests/test_display_data_fixes_20261008.py new file mode 100644 index 0000000..e5231a0 --- /dev/null +++ b/backend/tests/test_display_data_fixes_20261008.py @@ -0,0 +1,384 @@ +"""2026-10-08 표시 데이터 오류 회귀 — 차트 당일봉·종목명·스캐너 필드·AI 분석 일관성·WS 서브프로토콜·호가 캐시.""" +import asyncio +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +import pytz + +from app.api.endpoints import market_data as md +from app.api.endpoints import recommendations as rec + +KST = pytz.timezone("Asia/Seoul") + + +def _run(coro): + return asyncio.run(coro) + + +def _bar(d, c=47800.0): + return {"date": d, "open": c, "high": c, "low": c, "close": c, "volume": 1000} + + +# ── 1. 차트 ──────────────────────────────────────────────────────────────── +def test_session_state_midday_wed(): + now = KST.localize(datetime(2026, 10, 7, 11, 0)) # 수요일 장중 + assert md._chart_session_state(now) == ("20261007", True) + + +def test_session_state_before_open_uses_prev_trading_day(): + now = KST.localize(datetime(2026, 10, 7, 8, 0)) + assert md._chart_session_state(now) == ("20261006", False) + + +def _tick(price, op, hi, lo, vol): + return {"price": price, "open": op, "high": hi, "low": lo, "volume": vol} + + +def test_chart_missing_days_filled_and_today_synthesized(): + """DB 가 10/06 에서 멈춰도 10/07 은 네이버로 보충, 10/08 장중 봉은 tick 캐시로 합성(R1: KIS REST 미호출).""" + now = KST.localize(datetime(2026, 10, 8, 11, 0)) + db = [_bar("20261005"), _bar("20261006")] + naver = [{"stck_bsop_date": "20261007", "stck_oprc": "45000", "stck_hgpr": "46000", + "stck_lwpr": "44000", "stck_clpr": "44500", "acml_vol": "5000"}] + kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=AssertionError("차트 경로 KIS 호출 금지"))) + with patch.object(md, "korea_investment_api", kis), \ + patch("app.core.redis.get_json", AsyncMock(return_value=_tick(44100, 44500, 44800, 43900, 7777))), \ + patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=naver)): + bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) + assert [b["date"] for b in bars] == ["20261005", "20261006", "20261007", "20261008"] + assert live is True + assert bars[-1]["close"] == 44100.0 and bars[-1]["volume"] == 7777 + + +def test_chart_today_bar_replaced_not_duplicated(): + now = KST.localize(datetime(2026, 10, 8, 11, 0)) + db = [_bar("20261007"), _bar("20261008", 40000.0)] + kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=AssertionError("차트 경로 KIS 호출 금지"))) + with patch.object(md, "korea_investment_api", kis), \ + patch("app.core.redis.get_json", AsyncMock(return_value=_tick(44100, 44500, 44800, 43900, 1))): + bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) + assert len(bars) == 2 and bars[-1]["close"] == 44100.0 and live + + +def test_chart_after_close_no_synthesis_when_up_to_date(): + now = KST.localize(datetime(2026, 10, 8, 17, 0)) + db = [_bar("20261007"), _bar("20261008")] + kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=AssertionError("호출 금지"))) + with patch.object(md, "korea_investment_api", kis): + bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) + assert bars == db and live is False + + +def test_chart_merge_failure_returns_original(): + now = KST.localize(datetime(2026, 10, 8, 11, 0)) + db = [_bar("20261006")] + kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=RuntimeError("down"))) + with patch.object(md, "korea_investment_api", kis), \ + patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(side_effect=RuntimeError("x"))): + bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) + assert bars == db and live is False + + +def _nv(d, c): + return {"stck_bsop_date": d, "stck_oprc": str(c), "stck_hgpr": str(c), "stck_lwpr": str(c), + "stck_clpr": str(c), "acml_vol": "1"} + + +def test_chart_intraday_naver_partial_today_bar_dropped(): + now = KST.localize(datetime(2026, 10, 8, 11, 0)) + db = [_bar("20261006")] + naver = [_nv("20261007", 47800), _nv("20261008", 47000)] + with patch("app.core.redis.get_json", AsyncMock(return_value=None)), \ + patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=naver)): + bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) + assert [b["date"] for b in bars] == ["20261006", "20261007"] and live is False + + +def test_chart_interior_gap_backfilled(): + now = KST.localize(datetime(2026, 10, 8, 17, 0)) + db = [_bar("20261005"), _bar("20261007"), _bar("20261008")] # 10/06 결손 + naver = [_nv("20261005", 47800), _nv("20261006", 47800), _nv("20261007", 47800)] + with patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=naver)): + bars, _ = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) + assert [b["date"] for b in bars] == ["20261005", "20261006", "20261007", "20261008"] + + +def test_chart_source_mismatch_skips_merge(): + now = KST.localize(datetime(2026, 10, 8, 17, 0)) + db = [_bar("20261005", 100000.0), _bar("20261007", 100000.0), _bar("20261008", 100000.0)] + naver = [_nv("20261005", 50000), _nv("20261006", 50000), _nv("20261007", 50000)] # 분할 수정주가 + with patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=naver)): + bars, _ = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) + assert bars == db + + +def test_chart_small_close_gap_still_fills_missing_bar(): + """네이버-KRX 종가 1.3% 차이는 분할이 아니다 → DB 봉 유지 + 누락 날짜만 보충.""" + now = KST.localize(datetime(2026, 10, 8, 17, 0)) + db = [_bar("20261005", 100000.0), _bar("20261007", 100000.0), _bar("20261008", 100000.0)] + naver = [_nv("20261005", 101300), _nv("20261006", 100500), _nv("20261007", 99000)] + with patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=naver)): + bars, _ = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) + assert [b["date"] for b in bars] == ["20261005", "20261006", "20261007", "20261008"] + assert bars[0]["close"] == 100000.0 and bars[2]["close"] == 100000.0 + assert bars[1]["close"] == 100500.0 + + +# ── 2. 종목명 ────────────────────────────────────────────────────────────── +def test_name_prefers_kis_then_falls_back_to_naver(): + md._STOCK_NAME_MEM.clear() + assert _run(md._resolve_stock_name("111111", "KIS이름")) == "KIS이름" + md._STOCK_NAME_MEM.clear() + + class _BadSession: + def __call__(self): + raise RuntimeError("db down") + + with patch("app.core.database.AsyncSessionLocal", _BadSession()), \ + patch("app.services.naver_finance.get_naver_stock_name", AsyncMock(return_value="네이버이름")): + assert _run(md._resolve_stock_name("009420")) == "네이버이름" + assert md._STOCK_NAME_MEM["009420"] == "네이버이름" + md._STOCK_NAME_MEM.clear() + + +def test_name_from_kis_output_ignores_blank(): + assert md._name_from_kis_output({"hts_kor_isnm": " "}, {"prdt_abrv_name": "한올"}) == "한올" + + +# ── 3,4,6. 스캐너 ────────────────────────────────────────────────────────── +def _stock(**kw): + base = dict(stock_code="009420", stock_name="한올바이오파마", current_price=44000, + change_rate=1.5, volume=1_000_000, rsi=35, adx_14=20, ma5=1, ma20=1, + stochastic_k=10, signals=["RSI 과매도 (거래량부족-차단)"], score=3, ml_score=1, + ml_confidence=0.7, ml_signal="SELL", + analysis_date=datetime(2026, 10, 8).date(), + updated_at=datetime(2026, 10, 8, 9, 10, 3), created_at=None) + base.update(kw) + return SimpleNamespace(**base) + + +def test_scanner_fields_rule_vs_fallback_and_blocks(): + r = SimpleNamespace(id=1, name="평균회귀", description="d", priority=1) + d_rule = rec._stock_analysis_to_dict(_stock(signals=["RSI 과매도 + 거래량증가"], ml_signal="BUY"), rule=r) + assert d_rule["match_source"] == "rule" and d_rule["buy_allowed"] is True + assert d_rule["block_reasons"] == [] + d_fb = rec._stock_analysis_to_dict(_stock()) + assert d_fb["match_source"] == "fallback_high_score" + assert d_fb["buy_allowed"] is False + assert any("거래량부족-차단" in x for x in d_fb["block_reasons"]) + # ML_SELL_SIGNAL_ENABLED=False(기본)에서는 ML SELL 이 매수를 막지 않으므로 사유가 아니다 + assert "ML 신호 SELL" not in d_fb["block_reasons"] + assert d_fb["ml_blocks_buy"] is False and d_fb["ml_signal"] == "SELL" + assert d_fb["stock_code"] == "009420" and d_fb["rule_name"] == "고점수 분석 종목" # 기존 필드 유지 + + +def test_batch_time_is_kst_iso_with_offset(): + t = rec._batch_time_iso_v2([_stock()]) + assert t == "2026-10-08T09:10:03+09:00" + # 시각 정보가 없으면 분석일 00:00 KST + t2 = rec._batch_time_iso_v2([_stock(updated_at=None)]) + assert t2.endswith("+09:00") and t2.startswith("2026-10-08") + + +def test_overlay_recomputes_price_and_change_rate_without_mutating_original(): + item = {"stock_code": "009420", "current_price": 44000, "change_rate": 1.5} + result = {"recommendations": [item], "analyzed_stock_list": [dict(item)], + "recommendations_by_rule": [{"rule_id": 1, "stocks": [item]}]} + + async def fake_get_json(key): + return {"price": 45000.0, "change_rate": 3.77} if key == "tick:009420" else None + + with patch("app.core.redis.get_json", fake_get_json): + out = _run(rec._overlay_realtime_v2(result)) + o = out["recommendations"][0] + assert o["current_price"] == 45000 and o["change_rate"] == 3.77 and o["price_source"] == "realtime" + assert o["batch_change_rate"] == 1.5 + assert item["current_price"] == 44000 # 원본 불변 + assert out["recommendations_by_rule"][0]["stocks"][0]["price_source"] == "realtime" + + +def test_overlay_keeps_batch_when_no_tick(): + result = {"recommendations": [{"stock_code": "000001", "current_price": 1, "change_rate": 0.1}]} + with patch("app.core.redis.get_json", AsyncMock(return_value=None)): + out = _run(rec._overlay_realtime_v2(result)) + assert out["recommendations"][0]["price_source"] == "batch" + assert out["recommendations"][0]["change_rate"] == 0.1 + + +# ── 5. AI 분석 일관성 ────────────────────────────────────────────────────── +class _FakeDB: + def __init__(self, record): + self._r = record + + async def execute(self, *_a, **_k): + r = self._r + return SimpleNamespace(scalar_one_or_none=lambda: r) + + +def _ai(record, name_out=""): + kis = SimpleNamespace(get_current_price=AsyncMock(return_value={ + "rt_cd": "0", "output": {"stck_prpr": "44000", "stck_hgpr": "45000", "stck_lwpr": "43000", + "acml_vol": "10", "hts_kor_isnm": name_out}})) + md._STOCK_NAME_MEM.clear() + with patch.object(md, "korea_investment_api", kis), \ + patch.object(md, "_resolve_stock_name", AsyncMock(return_value="한올바이오파마")): + return _run(md.get_ai_analysis("009420", db=_FakeDB(record))) + + +def _rec(rsi): + return SimpleNamespace(rsi=rsi, ma5=None, ma20=None, ma60=None, stochastic_k=None, stochastic_d=None, + bollinger_upper=None, bollinger_lower=None, bollinger_middle=None, atr_14=800) + + +def test_ai_hold_has_no_targets_and_neutral_wording(): + out = _ai(_rec(35.0)) # RSI 35 → +0.5/1 = 0.5 → BUY 가 되지 않도록 신호 추가 없이 확인용 + # RSI 35 단독은 score 0.5 > 0.3 → BUY. HOLD 를 만들기 위해 약한 신호 케이스를 별도로 검증 + assert out["action"] == out["recommendation"] + assert "매수권장" not in out["ai_analysis"] + assert out["stock_name"] == "한올바이오파마" + + +def test_ai_hold_nulls_targets_and_headline(): + # RSI 55: 어떤 신호도 없음 → total_signals=1, score 0 → HOLD + out = _ai(_rec(55.0)) + assert out["recommendation"] == "HOLD" and out["action"] == "HOLD" + assert out["headline"] == "관망" + for k in ("target_price", "stop_loss_price", "target_pct", "stop_loss_pct", "risk_reward"): + assert out[k] is None, k + assert out["suggested_amount"] == 0 + + +def test_ai_buy_keeps_targets_and_headline(): + out = _ai(_rec(20.0)) # 과매도 → BUY + assert out["recommendation"] == "BUY" and out["headline"] == "매수 추천" + assert out["target_price"] and out["stop_loss_price"] and out["stop_loss_price"] < 44000 + + +# ── 7. WS 서브프로토콜 ───────────────────────────────────────────────────── +def test_ws_subprotocol_token_extraction(): + from app.core.security import extract_ws_subprotocol_token as ex + assert ex("autostock.auth, tok.en-1") == ("tok.en-1", "autostock.auth") + assert ex("foo, autostock.auth,abc") == ("abc", "autostock.auth") + assert ex("autostock.auth") == (None, "autostock.auth") + assert ex("other, proto") == (None, "") + assert ex(None) == (None, "") + + +# ── 8. 호가 캐시 ─────────────────────────────────────────────────────────── +def test_orderbook_ttl_cache_and_coalescing(): + md._orderbook_cache.clear() + calls = {"n": 0} + + async def fake_impl(code): + calls["n"] += 1 + await asyncio.sleep(0.05) + return {"stock_code": code, "asks": [{"price": 1, "volume": 1}], "bids": []} + + async def go(): + with patch.object(md, "_get_orderbook_impl", fake_impl): + a, b = await asyncio.gather(md.get_orderbook("009420"), md.get_orderbook("009420")) + c = await md.get_orderbook("009420") + return a, b, c + + a, b, c = _run(go()) + assert calls["n"] == 1 and a is b is c + md._orderbook_cache.clear() + + +def test_orderbook_error_not_cached(): + md._orderbook_cache.clear() + + async def bad(code): + return {"stock_code": code, "asks": [], "bids": [], "error": "temporarily_unavailable"} + + async def go(): + with patch.object(md, "_get_orderbook_impl", bad): + await md.get_orderbook("009420") + + _run(go()) + assert "009420" not in md._orderbook_cache + + +# ── 라운드5: ML 게이트 정의 일치 / 대체 목록 매수 제외 / MR 규칙 병합 ────────── +def test_ml_flags_match_trade_executor_gate(monkeypatch): + from app.core.config import settings + monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", True) + monkeypatch.setattr(settings, "ML_SELL_SIGNAL_ENABLED", False) + r = SimpleNamespace(id=1, name="n", description="d", priority=1) + ok_sig = ["RSI 과매도 + 거래량증가"] + hold = rec._stock_analysis_to_dict(_stock(signals=ok_sig, ml_signal="HOLD", ml_confidence=0.7), rule=r) + assert hold["buy_allowed"] is False and hold["ml_blocks_buy"] is True + low = rec._stock_analysis_to_dict(_stock(signals=ok_sig, ml_signal="HOLD", ml_confidence=0.59), rule=r) + assert low["buy_allowed"] is True + sell = rec._stock_analysis_to_dict(_stock(signals=ok_sig, ml_signal="SELL", ml_confidence=0.9), rule=r) + assert sell["buy_allowed"] is True and sell["ml_blocks_buy"] is False + monkeypatch.setattr(settings, "ML_SELL_SIGNAL_ENABLED", True) + sell2 = rec._stock_analysis_to_dict(_stock(signals=ok_sig, ml_signal="SELL", ml_confidence=0.9), rule=r) + assert sell2["buy_allowed"] is False + monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", False) + hold2 = rec._stock_analysis_to_dict(_stock(signals=ok_sig, ml_signal="HOLD", ml_confidence=0.9), rule=r) + assert hold2["ml_blocks_buy"] is False + + +def test_merge_mr_rule_flag_off_is_noop(monkeypatch): + monkeypatch.setattr(rec, "settings", SimpleNamespace(MEAN_REVERSION_RULE_MERGE_ENABLED=False)) + base = [rec.Rule(1, "b", "", "BUY", 1, {}, {}, True)] + assert rec._merge_json_mean_reversion_rule_v2(base) == base + + +def test_merge_mr_rule_flag_on_adds_once(monkeypatch): + monkeypatch.setattr(rec, "settings", SimpleNamespace(MEAN_REVERSION_RULE_MERGE_ENABLED=True)) + base = [rec.Rule(1, "b", "", "BUY", 1, {}, {}, True)] + merged = rec._merge_json_mean_reversion_rule_v2(base) + assert [r.rule_type for r in merged] == ["BUY", "MEAN_REVERSION"] + assert merged[1].id == 2 + assert rec._merge_json_mean_reversion_rule_v2(merged) == merged # 이미 있으면 불변 + + +def test_fallback_group_is_exit_only_unless_mr_gate_passes(monkeypatch): + import asyncio as _a + from unittest.mock import AsyncMock, MagicMock + s1 = _stock(stock_code="111111", signals=["x"], ml_signal=None) + s2 = _stock(stock_code="222222", signals=["x"], ml_signal=None) + res = MagicMock(); res.scalars.return_value.all.return_value = [s1, s2] + db = MagicMock(); db.execute = AsyncMock(return_value=res) + monkeypatch.setattr(rec, "_load_active_rules_from_db", AsyncMock(return_value=[])) + monkeypatch.setattr(rec, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"})) + monkeypatch.setattr(rec, "is_mean_reversion_candidate", lambda st, c, r: st.stock_code == "222222") + out = _a.run(rec.get_rule_based_recommendations_from_db(db)) + by = {d["stock_code"]: d for d in out["recommendations"]} + assert by["111111"]["_exit_only"] is True and by["111111"]["buy_allowed"] is False + assert by["222222"]["_exit_only"] is False + # 게이트 예외는 fail-closed + def boom(*a): raise RuntimeError("x") + monkeypatch.setattr(rec, "is_mean_reversion_candidate", boom) + out = _a.run(rec.get_rule_based_recommendations_from_db(db)) + assert all(d["_exit_only"] is True for d in out["recommendations"]) + + +def test_name_miss_is_negative_cached_r6(): + """이름 없음도 TTL 캐시 — 두 번째 호출은 DB/네이버를 다시 치지 않는다.""" + md._STOCK_NAME_MEM.clear() + md._STOCK_NAME_MISS.clear() + + class _CountSession: + calls = 0 + + def __call__(self): + _CountSession.calls += 1 + raise RuntimeError("db down") + + naver = AsyncMock(return_value="") + with patch("app.core.database.AsyncSessionLocal", _CountSession()), \ + patch("app.services.naver_finance.get_naver_stock_name", naver): + assert _run(md._resolve_stock_name("999999")) == "" + assert _run(md._resolve_stock_name("999999")) == "" + assert _CountSession.calls == 1 and naver.await_count == 1 + # TTL 경과 후에는 재조회 + md._STOCK_NAME_MISS["999999"] -= md._STOCK_NAME_MISS_TTL + 1 + _run(md._resolve_stock_name("999999")) + assert _CountSession.calls == 2 + md._STOCK_NAME_MISS.clear() + md._STOCK_NAME_MEM.clear() diff --git a/backend/tests/test_dt_api_engine_signals_closeall_20261009.py b/backend/tests/test_dt_api_engine_signals_closeall_20261009.py new file mode 100644 index 0000000..73b989b --- /dev/null +++ b/backend/tests/test_dt_api_engine_signals_closeall_20261009.py @@ -0,0 +1,320 @@ +"""/daytrading API 하드닝 회귀 테스트 (2026-10-09, 하드닝 루프 라운드 1). + +1. /close-all — 인메모리 포지션이 없으면 dt_cycle_lock 안에서 close_stale_positions() + 로 전일·고아 DT 잔고를 정리한다(예전엔 '활성 포지션 없음'으로 즉시 반환). +2. /signals·/execute(dry_run) — 레거시 모듈 상태(항상 빈 _orb_cache/_positions/ + _closed_today) 대신 엔진 상태(day_trading_state, entry_service 분봉 캐시)로 판정. + 레거시 매매 사이클은 매매하지 않는 정지 스텁. + +목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 로 계산. +""" +import asyncio +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest +import pytz +from fastapi import HTTPException + +from app.core.config import settings +from app.api.endpoints import day_trading as dt +from app.services.day_trading_strategy import ( + DayTradeSignal, MinuteCandle, OpeningRange, calculate_day_trade_position_size, +) +from app.services.daytrading import entry_service as ens +from app.services.daytrading import exit_service as xs +from app.services.daytrading.position_manager import day_trading_state, dt_cycle_lock + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +def _boom_sync(name): + def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +@pytest.fixture(autouse=True) +def _isolate_state(): + saved_pos = dict(day_trading_state.positions) + saved_closed = set(day_trading_state.closed_today) + saved_orb = dict(day_trading_state.orb_cache) + saved_pnl = day_trading_state.daily_pnl + saved_assets = day_trading_state.last_total_assets + saved_wl = dict(ens._watchlist) + saved_wl_auto = ens._watchlist_auto + day_trading_state.reset_if_new_day() + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.orb_cache.clear() + day_trading_state.daily_pnl = 0.0 + ens._candle_cache.clear() + # 레거시 모듈 상태는 비어 있다는 전제(실운영과 동일) + dt._orb_cache.clear() + dt._positions.clear() + dt._closed_today.clear() + # 주문 공통 종단은 어떤 테스트에서도 호출되면 안 된다 + with patch.object(xs, "_execute_exit_impl", _boom("_execute_exit_impl")), \ + patch.object(ens, "_place_and_record_entry_impl", _boom("_place_and_record_entry_impl")): + yield + day_trading_state.positions.clear() + day_trading_state.positions.update(saved_pos) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved_closed) + day_trading_state.orb_cache.clear() + day_trading_state.orb_cache.update(saved_orb) + day_trading_state.daily_pnl = saved_pnl + day_trading_state.last_total_assets = saved_assets + ens._watchlist = saved_wl + ens._watchlist_auto = saved_wl_auto + ens._candle_cache.clear() + + +# ═════════════════════════════════════════════════════════════════════════════ +# 1. /close-all — 인메모리 비어 있으면 전일·고아 잔고 정리 +# ═════════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_close_all_empty_memory_closes_stale_under_cycle_lock(): + seen = [] + + async def _stale(): + seen.append(dt_cycle_lock.locked()) + return [{"stock_code": "111111", "reason": "전일 미청산"}] + + with patch.object(dt, "is_market_open", return_value=True), \ + patch.object(xs, "close_stale_positions", _stale), \ + patch.object(xs, "force_close_all", _boom("force_close_all")): + res = await dt.close_all_positions() + + assert seen == [True], "close_stale_positions 는 dt_cycle_lock 안에서 1회 호출돼야 한다" + assert res["stale_checked"] is True + assert res["exits"] == [{"stock_code": "111111", "reason": "전일 미청산"}] + assert res["stale_exits"] == res["exits"] + assert "1건" in res["message"] + assert not dt_cycle_lock.locked() + + +@pytest.mark.asyncio +async def test_close_all_empty_memory_market_closed_does_not_sell(): + with patch.object(dt, "is_market_open", return_value=False), \ + patch.object(xs, "close_stale_positions", _boom("close_stale_positions")), \ + patch.object(xs, "force_close_all", _boom("force_close_all")): + res = await dt.close_all_positions() + assert res["stale_checked"] is False + assert res["exits"] == [] + + +@pytest.mark.asyncio +async def test_close_all_empty_memory_rejects_while_cycle_holds_lock(): + with patch.object(dt, "is_market_open", return_value=True), \ + patch.object(xs, "close_stale_positions", _boom("close_stale_positions")): + async with dt_cycle_lock: + with pytest.raises(HTTPException) as ei: + # 락을 기다리면(=회귀) 교착 대신 TimeoutError 로 실패하게 상한을 둔다 + await asyncio.wait_for(dt.close_all_positions(), timeout=1.0) + assert ei.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_close_all_with_positions_still_delegates_to_force_close_all(): + day_trading_state.positions["222222"] = {"entry_price": 10_000.0, "quantity": 3} + fca = AsyncMock(return_value=[{"stock_code": "222222"}]) + with patch.object(xs, "force_close_all", fca), \ + patch.object(xs, "close_stale_positions", _boom("close_stale_positions")): + res = await dt.close_all_positions() + fca.assert_awaited_once() + assert res["exits"] == [{"stock_code": "222222"}] + + +# ── [R4] 보유 경로도 dt_cycle_lock 안에서 청산 ──────────────────────────────── + +@pytest.mark.asyncio +async def test_close_all_with_positions_runs_force_close_under_cycle_lock(): + """보유 경로 force_close_all 이 스케줄러 진입 스캔과 겹치지 않게 dt_cycle_lock 안에서 호출.""" + day_trading_state.positions["333333"] = {"entry_price": 10_000.0, "quantity": 2} + seen = [] + + async def _fca(reason="강제 청산"): + seen.append(dt_cycle_lock.locked()) + day_trading_state.positions.pop("333333", None) + return [{"stock_code": "333333"}] + + with patch.object(xs, "force_close_all", _fca), \ + patch.object(xs, "close_stale_positions", _boom("close_stale_positions")): + res = await dt.close_all_positions() + assert seen == [True] + assert res["remaining_positions"] == [] + assert "잔존" not in res["message"] + assert not dt_cycle_lock.locked() + + +@pytest.mark.asyncio +async def test_close_all_with_positions_rejects_while_cycle_holds_lock(): + """사이클(진입 스캔 포함)이 락을 쥐고 있으면 기다리지 않고 409 — 청산 경로 미호출.""" + day_trading_state.positions["444444"] = {"entry_price": 10_000.0, "quantity": 1} + with patch.object(xs, "force_close_all", _boom("force_close_all")), \ + patch.object(xs, "close_stale_positions", _boom("close_stale_positions")): + async with dt_cycle_lock: + with pytest.raises(HTTPException) as ei: + await asyncio.wait_for(dt.close_all_positions(), timeout=1.0) + assert ei.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_close_all_reports_positions_remaining_after_force_close(): + """청산 후에도 남은 포지션(진행 중·실패)은 '청산 완료' 문구에 숨기지 않고 드러낸다.""" + day_trading_state.positions["555555"] = {"entry_price": 10_000.0, "quantity": 1} + day_trading_state.positions["666666"] = {"entry_price": 10_000.0, "quantity": 1} + + async def _fca(reason="강제 청산"): + day_trading_state.positions.pop("555555", None) + return [{"stock_code": "555555"}] + + with patch.object(xs, "force_close_all", _fca), \ + patch.object(xs, "close_stale_positions", _boom("close_stale_positions")): + res = await dt.close_all_positions() + assert res["remaining_positions"] == ["666666"] + assert "1건 잔존" in res["message"] + + +# ═════════════════════════════════════════════════════════════════════════════ +# 2. /signals·/execute — 엔진 상태 기준 +# ═════════════════════════════════════════════════════════════════════════════ + +_KST = pytz.timezone("Asia/Seoul") + + +def _today_candles(n: int = 30, price: float = 10_000.0) -> list: + today = datetime.now(_KST).strftime("%Y%m%d") + out = [] + for i in range(n): + hh, mm = divmod(9 * 60 + 30 + i, 60) + out.append(MinuteCandle( + date=today, time=f"{hh:02d}{mm:02d}00", + open=price, high=price + 10, low=price - 10, close=price, + volume=1000, cum_volume=1000 * (i + 1), cum_amount=price * 1000 * (i + 1), + )) + return out + + +@pytest.mark.asyncio +async def test_signals_use_engine_state_orb_held_closed_and_dt_budget(): + total_assets = 10_000_000.0 + available_cash = 5_000_000.0 + price = 10_000.0 + # 엔진 상태: 감시 3종목 중 1개 보유(DT 투입액 차감 대상), 1개 당일 청산 + ens._watchlist = {"AAAAAA": "에이", "BBBBBB": "비", "CCCCCC": "씨"} + held_amount = total_assets * settings.DAY_TRADE_MAX_RATIO_DT * 0.9 + day_trading_state.positions["BBBBBB"] = { + "entry_price": held_amount / 100, "quantity": 100, "stock_name": "비", + } + day_trading_state.closed_today.add("CCCCCC") + engine_orb = OpeningRange(high=9_990.0, low=9_900.0, total_volume=10_000, + avg_vol_per_min=100.0, open_price=9_950.0) + day_trading_state.orb_cache["AAAAAA"] = engine_orb + + fetched = [] + + async def _candles(code): + fetched.append(code) + return _today_candles(price=price) + + orb_seen = [] + confidence = 0.8 + + def _orb_signal(code, name, candles, orb, cur): + orb_seen.append(orb) + return DayTradeSignal( + stock_code=code, stock_name=name, strategy="ORB", entry_price=cur, + stop_loss=cur * 0.98, target_price=cur * 1.03, reason="test", confidence=confidence, + ) + + with patch.object(dt, "is_day_trade_entry_window", return_value=True), \ + patch.object(dt, "_get_portfolio_summary", + AsyncMock(return_value={"total_assets": total_assets, + "available_cash": available_cash})), \ + patch.object(ens, "_get_candles_cached", _candles), \ + patch.object(dt.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)), \ + patch("app.services.market_playbook.is_vi_active", return_value=False), \ + patch.object(dt, "check_orb_signal", _orb_signal), \ + patch.object(dt, "check_vwap_bounce_signal", _boom_sync("check_vwap_bounce_signal")), \ + patch.object(dt, "check_rsi_bb_signal", _boom_sync("check_rsi_bb_signal")): + res = await dt.get_current_signals() + + assert res["source"] == "engine" + assert fetched == ["AAAAAA"], "보유·당일청산 종목은 판정하지 않아야 한다" + assert orb_seen == [engine_orb], "ORB 는 엔진 day_trading_state 의 값을 써야 한다" + assert res["skipped_held"] == 1 and res["skipped_closed_today"] == 1 + + # DT 투입액(보유분) 차감 후 세 한도 재클램프 — 기대값은 settings 로 계산 + budget = calculate_day_trade_position_size(total_assets, available_cash, held_amount) + budget = min( + budget * min(1.2, confidence / 0.7), + total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO, + total_assets * settings.DAY_TRADE_MAX_RATIO_DT - held_amount, + available_cash, + ) + expected_qty = int(max(0.0, budget) // price) + assert expected_qty > 0 + assert res["count"] == 1 + assert res["signals"][0]["stock_code"] == "AAAAAA" + assert res["signals"][0]["quantity"] == expected_qty + # 레거시처럼 보유분을 0 으로 보면 종목당 상한까지 커진다 — 그 값과 달라야 의미 있는 검증 + assert expected_qty < int(total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO // price) + + +@pytest.mark.asyncio +async def test_signals_respects_open_slots(): + ens._watchlist = {"AAAAAA": "에이"} + for i in range(dt.MAX_POSITIONS): + day_trading_state.positions[f"H{i:05d}"] = {"entry_price": 1.0, "quantity": 1} + with patch.object(dt, "is_day_trade_entry_window", return_value=True), \ + patch.object(dt, "_get_portfolio_summary", + AsyncMock(return_value={"total_assets": 10_000_000.0, + "available_cash": 5_000_000.0})), \ + patch.object(dt, "_evaluate_engine_signal_v2", AsyncMock(return_value={ + "signal": DayTradeSignal("AAAAAA", "에이", "RSI_BB", 10_000.0, 9_800.0, + 10_300.0, "t", 0.7), + "quantity": 5, "skip": None})): + res = await dt.get_current_signals() + assert res["gates"]["open_slots"] == 0 + assert res["signals"] == [] + + +@pytest.mark.asyncio +async def test_execute_dry_run_uses_engine_positions_and_closed_today(): + from app.api.endpoints.day_trading import ExecuteRequest + day_trading_state.positions["AAAAAA"] = {"entry_price": 10_000.0, "quantity": 1} + day_trading_state.closed_today.add("BBBBBB") + with patch.object(dt, "_evaluate_engine_signal_v2", _boom("_evaluate_engine_signal_v2")): + with pytest.raises(HTTPException) as e1: + await dt.execute_day_trade(ExecuteRequest(stock_code="AAAAAA", dry_run=True)) + with pytest.raises(HTTPException) as e2: + await dt.execute_day_trade(ExecuteRequest(stock_code="BBBBBB", dry_run=True)) + with pytest.raises(HTTPException) as e3: + await dt.execute_day_trade(ExecuteRequest(stock_code="CCCCCC", dry_run=False)) + assert e1.value.status_code == 400 + assert e2.value.status_code == 400 + assert e3.value.status_code == 410 + + +@pytest.mark.asyncio +async def test_legacy_cycle_is_inert_and_legacy_trading_helpers_removed(): + with patch.object(dt, "is_market_open", _boom_sync("is_market_open")), \ + patch.object(dt.korea_investment_api, "get_balance", _boom("get_balance")): + res = await dt.run_day_trading_cycle() + assert res["phase"] == "deprecated" + assert res["entries"] == [] and res["exits"] == [] + for name in ("_check_exits", "_scan_signals", "_execute_entry", "_execute_eod_flat", + "restore_positions_from_db", "_place_buy_order", "_place_sell_order", + "_collect_opening_range"): + assert not hasattr(dt, name), f"레거시 매매 경로 {name} 가 남아 있다" diff --git a/backend/tests/test_dt_daily_state_rebuild_r2_20261009.py b/backend/tests/test_dt_daily_state_rebuild_r2_20261009.py new file mode 100644 index 0000000..9c8505c --- /dev/null +++ b/backend/tests/test_dt_daily_state_rebuild_r2_20261009.py @@ -0,0 +1,274 @@ +"""[2026-10-09 하드닝 R2] DT 일일 안전장치 복원의 Redis 단일 소스 fail-open 회귀 테스트. + +결함: load_daily_state() 가 Redis 에서만 복원 → Redis 재기동·volatile-lru 축출·저장 실패 뒤 +재기동하면 daily_pnl=0 / 연패 0 / closed_today=∅ 로 같은 날 매매를 재개했다(warning 만). +수정: Redis 미스 시 당일 TradeHistory(SELECT 전용)로 재구성, 실패하면 DT 신규 진입 차단 + +텔레그램 + 재시도. +""" +import asyncio +import inspect +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.day_trading_strategy import is_daily_loss_exceeded +from app.services.daytrading import position_manager as pm +from app.services.daytrading.position_manager import DayTradingState + + +class _DBDown(Exception): + pass + + +def _row(i, code, order_type, net=None, status="COMPLETED", reason="", strategy="daytrading"): + now = datetime.combine(pm._today_kst(), datetime.min.time()).replace(hour=9, minute=10 + i) + return SimpleNamespace( + id=i, stock_code=code, order_type=order_type, status=status, exit_reason=reason, + net_profit_loss=net, strategy_type=strategy, created_at=now, + ) + + +class _Result: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return self + + def all(self): + return list(self._rows) + + +class _Session: + def __init__(self, rows): + self._rows = rows + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *_a, **_k): + return _Result(self._rows) + + +def _factory(rows): + return lambda: _Session(rows) + + +def _raising_factory(): + def _f(): + raise _DBDown("db down") + return _f + + +@pytest.fixture(autouse=True) +def _isolate(): + pm._daily_state_retry_task = None + notify = AsyncMock(return_value=None) + with patch.object(DayTradingState, "save_daily_state", AsyncMock(return_value=True)), \ + patch("app.services.telegram_notifier.telegram_notifier.notify_system_warning", notify): + yield notify + t = pm._daily_state_retry_task + if t is not None and not t.done(): + t.cancel() + pm._daily_state_retry_task = None + + +def _state(): + st = DayTradingState() + st.begin_restore() + return st + + +def _assets_setter(st, value): + async def _ensure(): + st.last_total_assets = value + return value + return _ensure + + +def _losses_over_limit(total_assets): + """-3% 한도(settings)를 넘는 손실 3건(연패 3).""" + per = abs(float(settings.DAY_TRADE_DAILY_LOSS_LIMIT)) * total_assets / 2 + return [ + _row(0, "A00001", "BUY"), _row(1, "A00001", "SELL", -per), + _row(2, "A00002", "BUY"), _row(3, "A00002", "SELL", -per), + _row(4, "A00003", "BUY"), _row(5, "A00003", "SELL", -1000.0, reason="시간손절 30분"), + ] + + +# ── 1. Redis 미스 → 원장으로 -3% 한도·3연패·재진입 차단 복원 ──────────────────── + +def test_rebuild_restores_loss_limit_streak_and_closed_today(): + async def _t(): + st = _state() + assets = 10_000_000.0 + rows = _losses_over_limit(assets) + with patch("app.services.daytrading.exit_service._ensure_total_assets_for_exit_impl", + _assets_setter(st, assets)): + ok = await st.rebuild_daily_state_after_redis_miss_v2(_factory(rows)) + assert ok is True + assert st.daily_state_unverified is False + assert st.daily_state_source == "trade_history" + expected = sum(float(r.net_profit_loss) for r in rows if r.order_type == "SELL") + assert st.daily_pnl == pytest.approx(expected) + assert is_daily_loss_exceeded(st.last_total_assets, st.daily_pnl) is True + assert st.loss_streak_today == 3 # 시간손절도 당일 연패에 포함(§5.1) + assert st.is_cooldown_active() is True + assert {"A00001", "A00002", "A00003"} <= st.closed_today + assert st.is_closed_today("A00001") + asyncio.run(_t()) + + +def test_stale_and_orphan_sells_excluded_from_daily_pnl_but_block_reentry(): + async def _t(): + st = _state() + st.last_total_assets = 10_000_000.0 + rows = [ + _row(0, "S00001", "SELL", -500_000.0, reason="전일 미청산 포지션 강제 청산 (진입일 x)"), + _row(1, "S00002", "SELL", -400_000.0, reason="DT 고아 잔고 청산 (진입일 x)"), + _row(2, "S00003", "SELL", -300_000.0, reason="손절"), # 당일 BUY 없음 = 전일분 + _row(3, "B00001", "BUY"), _row(4, "B00001", "SELL", 2_000.0), + ] + ok = await st.rebuild_daily_state_after_redis_miss_v2(_factory(rows)) + assert ok is True + assert st.daily_pnl == pytest.approx(2_000.0) + assert st.loss_streak_today == 0 + assert {"S00001", "S00002", "S00003", "B00001"} <= st.closed_today + asyncio.run(_t()) + + +def test_stale_prefixes_match_exit_service_reasons(): + from app.services.daytrading import exit_service as es + src = inspect.getsource(es) + for p in pm._STALE_EXIT_REASON_PREFIXES: + assert p in src, f"exit_service 의 전일·고아 청산 사유 문자열과 동기화 필요: {p}" + + +def test_partial_sell_not_counted_as_full_close_and_cancelled_ignored(): + rows = [ + _row(0, "P00001", "BUY"), + _row(1, "L00001", "BUY"), _row(2, "L00001", "SELL", -100.0), + _row(3, "P00001", "SELL", 900.0), # 분할 익절 — 연패 리셋하지 않음 + _row(4, "L00002", "BUY"), _row(5, "L00002", "SELL", -100.0), + _row(6, "P00001", "SELL", -50.0), # 잔량 전량 — 레그는 손실이나 왕복 +850 (R3: 이익 → 리셋) + _row(7, "C00001", "BUY"), _row(8, "C00001", "SELL", -99_999.0, status="CANCELLED"), + _row(9, "W00001", "SELL", 10.0, strategy="swing"), + _row(10, "L00003", "BUY"), _row(11, "L00003", "SELL", -100.0), + ] + r = pm._rebuild_daily_state_from_rows(rows) + # [R3] L1(-) → 1, L2(-) → 2, P 왕복 +850 → 0, L3(-) → 1. 분할 레그 단독으로는 리셋하지 않는다. + assert r["loss_streak_today"] == 1 + assert r["daily_pnl"] == pytest.approx(-100.0 + 900.0 - 100.0 - 50.0 - 100.0) + assert "C00001" in r["closed_today"] and "C00001" not in r["dt_exited_today"] + assert "W00001" in r["closed_today"] # 전략 간 당일 쿨다운 + + +def test_no_rows_is_verified_empty_state(): + async def _t(): + st = _state() + ok = await st.rebuild_daily_state_after_redis_miss_v2(_factory([])) + assert ok is True + assert st.daily_state_unverified is False + assert st.is_cooldown_active() is False + asyncio.run(_t()) + + +# ── 2. 재구성 실패 → 신규 진입 차단 + 텔레그램 + 재시도로 해제 ──────────────────── + +def test_db_failure_blocks_entry_and_alerts(_isolate): + async def _t(): + st = _state() + with patch.object(pm, "_DAILY_STATE_REBUILD_RETRY_SEC", 3600.0): + ok = await st.rebuild_daily_state_after_redis_miss_v2(_raising_factory()) + assert ok is False + assert st.daily_state_unverified is True + assert "db down" in st.daily_state_unverified_reason + assert st.is_cooldown_active() is True + assert _isolate.await_count == 1 + assert pm._daily_state_retry_task is not None + asyncio.run(_t()) + + +def test_retry_clears_block_when_db_recovers(_isolate): + async def _t(): + st = _state() + calls = {"n": 0} + rows = [_row(0, "R00001", "BUY"), _row(1, "R00001", "SELL", 1_000.0)] + + def _flaky(): + calls["n"] += 1 + if calls["n"] == 1: + raise _DBDown("db down") + return _Session(rows) + + with patch.object(pm, "_DAILY_STATE_REBUILD_RETRY_SEC", 0.01): + ok = await st.rebuild_daily_state_after_redis_miss_v2(_flaky) + assert ok is False and st.is_cooldown_active() is True + await asyncio.wait_for(pm._daily_state_retry_task, timeout=2.0) + assert st.daily_state_unverified is False + assert st.is_cooldown_active() is False + assert st.daily_pnl == pytest.approx(1_000.0) + assert _isolate.await_count == 2 # 차단 알림 + 해제 알림 + asyncio.run(_t()) + + +def test_loss_without_total_assets_stays_blocked(): + """손실인데 분모 0 → is_daily_loss_exceeded 가 무조건 False 라 한도 무력화 → 미검증 유지.""" + async def _t(): + st = _state() + rows = [_row(0, "X00001", "BUY"), _row(1, "X00001", "SELL", -1_000_000.0)] + boom = AsyncMock(side_effect=_DBDown("kis down")) + with patch("app.services.daytrading.exit_service._ensure_total_assets_for_exit_impl", boom), \ + patch.object(pm, "_DAILY_STATE_REBUILD_RETRY_SEC", 3600.0): + ok = await st.rebuild_daily_state_after_redis_miss_v2(_factory(rows)) + assert boom.await_count == 1 + assert ok is False + assert st.daily_pnl == pytest.approx(-1_000_000.0) + assert st.is_cooldown_active() is True + asyncio.run(_t()) + + +def test_merge_is_conservative_with_in_memory_values(): + async def _t(): + st = _state() + st.last_total_assets = 10_000_000.0 + st.daily_pnl = -5_000.0 + st.loss_streak_today = 2 + st.closed_today = {"M00001"} + rows = [_row(0, "N00001", "BUY"), _row(1, "N00001", "SELL", -3_000.0)] + await st.rebuild_daily_state_after_redis_miss_v2(_factory(rows)) + assert st.daily_pnl == pytest.approx(-5_000.0) # min, 이중 합산 아님 + assert st.loss_streak_today == 2 + assert st.closed_today == {"M00001", "N00001"} + asyncio.run(_t()) + + +def test_new_day_reset_clears_unverified_block(): + st = _state() + st.daily_state_unverified = True + st.last_reset_date = None + assert st.reset_if_new_day() is True + assert st.daily_state_unverified is False + assert st.is_cooldown_active() is False + + +# ── 3. 배선 ───────────────────────────────────────────────────────────────── + +def test_scheduler_calls_rebuild_only_after_redis_miss(): + from app.services.daytrading import scheduler as sched + ssrc = inspect.getsource(sched.daytrading_scheduler) + i_load = ssrc.index("load_daily_state()") + i_rebuild = ssrc.index("rebuild_daily_state_after_redis_miss_v2()") + assert ssrc.index("begin_restore()") < i_load < i_rebuild + + +def test_exit_paths_do_not_consult_entry_block(): + """미검증 차단은 진입만 막는다 — 청산 경로가 is_cooldown_active 를 보면 안 된다(§8.1-11).""" + from app.services.daytrading import exit_service as es + assert "is_cooldown_active" not in inspect.getsource(es) diff --git a/backend/tests/test_dt_entry_executed_at_r8_20261010.py b/backend/tests/test_dt_entry_executed_at_r8_20261010.py new file mode 100644 index 0000000..83e7169 --- /dev/null +++ b/backend/tests/test_dt_entry_executed_at_r8_20261010.py @@ -0,0 +1,91 @@ +"""DT 진입 하드닝 라운드 8 회귀 테스트 (2026-10-10). + +[LOW] DT 매수 TradeHistory payload 에 executed_at 이 없어(운영 10/01 이후 DT 58행 전부 NULL) +sync_portfolio 의 executed_at 시간창 조회(ghost 보존·팬텀 close 유예)가 DT 체결을 보지 못하던 결함 +→ payload 에 executed_at(KST naive, 진입 시각)을 싣는다. + +목킹 원칙(§4.1): 주문 공통 종단(safe_place_order)은 도달 시 BaseException. +""" +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +import pytz + +from app.models.models import TradeHistory +from app.services.daytrading import entry_service as ens +from app.services.daytrading.position_manager import day_trading_state + +KST = pytz.timezone("Asia/Seoul") +CODE = "777778" +ODNO = "0000888" + + +class MustNotBeCalled(BaseException): + pass + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch): + import app.services.trade_executor as te + import app.services.portfolio_cache_bridge as pcb + saved = dict(day_trading_state.positions) + day_trading_state.positions.clear() + ens._pending_entries.clear() + monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order")) + monkeypatch.setattr(pcb, "reflect_buy", AsyncMock()) + monkeypatch.setattr(ens, "enqueue_active_trade_upsert", AsyncMock()) + monkeypatch.setattr(ens, "_schedule_buy_fill_reconcile", lambda *a, **k: None) + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)): + yield + day_trading_state.positions.clear() + day_trading_state.positions.update(saved) + ens._pending_entries.clear() + + +def _signal(): + return SimpleNamespace(strategy="RSI_BB", stop_loss=9_700.0, target_price=10_450.0, reason="t") + + +@pytest.mark.asyncio +async def test_accepted_entry_trade_history_has_executed_at_kst_now(monkeypatch): + rows = [] + + async def _th(p): + rows.append(p) + + monkeypatch.setattr(ens, "enqueue_trade_history", _th) + before = datetime.now(KST).replace(tzinfo=None) + await ens._record_accepted_entry_impl(CODE, "테스트", _signal(), 10, 10_000.0, ODNO) + after = datetime.now(KST).replace(tzinfo=None) + + assert len(rows) == 1 + ex = rows[0]["executed_at"] + assert isinstance(ex, datetime) and ex.tzinfo is None # KST naive (created_at 규약과 동일) + assert before <= ex <= after + # 인메모리 포지션 entry_time 과 일치 + assert day_trading_state.positions[CODE]["entry_time"] == ex + # db_worker INSERT 경로(TradeHistory(**data))가 받아들이는 키인지 + assert TradeHistory(**rows[0]).executed_at == ex + + +@pytest.mark.asyncio +async def test_unknown_confirmed_entry_uses_order_send_time(monkeypatch): + rows = [] + + async def _th(p): + rows.append(p) + + monkeypatch.setattr(ens, "enqueue_trade_history", _th) + since = datetime.now(KST).replace(tzinfo=None) - timedelta(minutes=3) + await ens._record_accepted_entry_impl( + CODE, "테스트", _signal(), 10, 10_000.0, ODNO, entry_time=since, reason_suffix=" [x]", + ) + assert rows[0]["executed_at"] == since diff --git a/backend/tests/test_dt_entry_hardening_r2_20261009.py b/backend/tests/test_dt_entry_hardening_r2_20261009.py new file mode 100644 index 0000000..3a0ff4f --- /dev/null +++ b/backend/tests/test_dt_entry_hardening_r2_20261009.py @@ -0,0 +1,435 @@ +"""DT 진입 하드닝 라운드 2 회귀 테스트 (2026-10-09). + +1. [MEDIUM] 자동 watchlist 30분 갱신이 ORB 보유 종목을 앞에 고정하고 상한으로 잘라 + 하루 종일 09:00 목록으로 동결되던 결함 + 신규/수집 실패 종목에 ORB 가 영영 없던 결함 + → 점수 순위 교체 + 선조회 분봉으로 ORB 지연 보충(추가 KIS 호출 없음). +2. [MEDIUM] DT 진입이 같은 종목의 타전략(스윙/외부/동기화) 보유를 확인하지 않던 결함 + → watchlist(DB 스냅샷, 읽기 전용) + 스캔 후보·주문 직전(KIS 잔고 output1) 이중 제외. +3. [MEDIUM] DT 진입 경로에 가격 필터(settings.MIN_PRICE_KRW)·레버리지 20% 가드 부재. + +목킹 원칙(§4.1): 도달하면 안 되는 주문은 공통 종단(trade_executor.safe_place_order)에 +BaseException 발생형으로 건다. 기대값은 settings 에서 계산한다. +""" +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.compiler import compiles + +from app.core.config import settings +from app.models.models import ActiveTrade, Base, Portfolio, StockAnalysis +from app.services.day_trading_strategy import MinuteCandle +from app.services.daytrading import entry_service as ens +from app.services.daytrading.position_manager import day_trading_state + + +@compiles(JSONB, "sqlite") +def _jsonb_as_json(type_, compiler, **kw): # 테스트용 SQLite 호환 + return "JSON" + + +class OrderReached(BaseException): + """주문 공통 종단 도달 (except Exception 에 삼켜지지 않게 BaseException).""" + + +async def _order_reached(*a, **k): + raise OrderReached("safe_place_order 도달") + + +TOTAL = 100_000_000.0 +CASH = 50_000_000.0 + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch): + saved_pos = dict(day_trading_state.positions) + saved_closed = set(day_trading_state.closed_today) + saved_orb = dict(day_trading_state.orb_cache) + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.orb_cache.clear() + ens._pending_entries.clear() + ens._candle_cache.clear() + monkeypatch.setattr(ens, "_non_dt_held_db", set()) + monkeypatch.setattr(ens, "_kis_holdings_snapshot", {}) + monkeypatch.setattr(ens, "_kis_holdings_verified", True) + ens.set_watchlist([]) + yield + day_trading_state.positions.clear() + day_trading_state.positions.update(saved_pos) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved_closed) + day_trading_state.orb_cache.clear() + day_trading_state.orb_cache.update(saved_orb) + ens._pending_entries.clear() + ens._candle_cache.clear() + ens.set_watchlist([]) + + +# ───────────────────────────────────────────────────────────────────────────── +# DB 헬퍼 (SQLite 인메모리 — 실 DB 미사용) +# ───────────────────────────────────────────────────────────────────────────── + +async def _mk_session(with_holdings: bool = True): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + tables = [StockAnalysis.__table__] + if with_holdings: + tables += [ActiveTrade.__table__, Portfolio.__table__] + async with engine.begin() as conn: + await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=tables)) + return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +def _today(): + return datetime.now(ens._KST).date() + + +def _sa(code, score=5, price=None): + return StockAnalysis( + stock_code=code, stock_name=f"N{code}", analysis_date=_today(), score=score, + rsi=40.0, current_price=price if price is not None else settings.MIN_PRICE_KRW * 2, + ) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 1. watchlist 갱신 동결 / ORB 지연 보충 +# ═════════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_refresh_replaces_by_score_even_when_all_have_orb(): + """기존 목록이 전부 ORB 를 가져도 갱신은 점수 순위로 교체된다(no-op 동결 해소).""" + engine, Session = await _mk_session() + async with Session() as s: + s.add_all([_sa("900001", 9), _sa("900002", 8), _sa("900003", 7)]) + await s.commit() + ens.set_watchlist([{"code": c, "name": c} for c in ("100001", "100002", "100003")]) + for c in ("100001", "100002", "100003"): + day_trading_state.set_orb(c, object()) + + with patch("app.core.database.AsyncSessionLocal", Session): + n = await ens.populate_daytrading_watchlist(min_score=1, max_stocks=3) + assert n == 3 + assert set(ens.get_watchlist()) == {"900001", "900002", "900003"} + await engine.dispose() + + +@pytest.mark.asyncio +async def test_refresh_pads_with_orb_holders_when_candidates_short(): + """점수 후보가 상한 미만이면 기존 ORB 보유 종목으로 채운다(수집한 ORB 보존).""" + engine, Session = await _mk_session() + async with Session() as s: + s.add_all([_sa("900001", 9)]) + await s.commit() + ens.set_watchlist([{"code": "100001", "name": "A"}, {"code": "100002", "name": "B"}]) + day_trading_state.set_orb("100001", object()) + + with patch("app.core.database.AsyncSessionLocal", Session): + n = await ens.populate_daytrading_watchlist(min_score=1, max_stocks=3) + wl = ens.get_watchlist() + assert n == 2 + assert set(wl) == {"900001", "100001"}, "ORB 없는 옛 종목은 보충 대상이 아니다" + await engine.dispose() + + +def _orb_day(price=10_000.0, start_hhmm=(9, 0), count=60): + d = datetime.now(ens._KST).strftime("%Y%m%d") + out = [] + for i in range(count): + t = start_hhmm[0] * 60 + start_hhmm[1] + i + hh, mm = divmod(t, 60) + out.append(MinuteCandle( + date=d, time=f"{hh:02d}{mm:02d}00", open=price, high=price + 10 + i, + low=price - 10, close=price, volume=1000, cum_volume=1000 * (i + 1), + cum_amount=price * 1000 * (i + 1), + )) + return out + + +def _at(hh, mm): + fake_now = datetime.now(ens._KST).replace(hour=hh, minute=mm, second=0) + + class _DT(datetime): + @classmethod + def now(cls, tz=None): + return fake_now if tz else fake_now.replace(tzinfo=None) + return _DT + + +def test_orb_backfill_from_prefetched_candles_after_0930(monkeypatch): + """09:30 이후 ORB 없는 감시종목은 선조회 분봉으로 ORB 가 채워진다(KIS 호출 없음).""" + import time as _t + monkeypatch.setattr(ens, "datetime", _at(10, 0)) + monkeypatch.setattr(settings, "DAY_TRADE_ORB_FULL_RANGE", False) + ens._candle_cache["200001"] = (_t.monotonic(), _orb_day()) + n = ens._backfill_orb_from_cache_v2(["200001"]) + assert n == 1 + orb = day_trading_state.get_orb("200001") + # [R2-2] FULL_RANGE=False: 평상시와 같은 '첫 N봉' 정의(09:00~09:04) — 30봉 레인지가 아니다 + n_bars = ens._ORB_FIRST_BARS + assert orb.high == 10_000.0 + 10 + (n_bars - 1) + assert orb.low == 10_000.0 - 10 + assert round(orb.total_volume / orb.avg_vol_per_min) == n_bars + + +def test_orb_backfill_full_range_on_uses_whole_window(monkeypatch): + """FULL_RANGE=True 면 09:00~09:29 창 전체(30봉) — 09:30 이후 봉은 섞이지 않는다.""" + import time as _t + monkeypatch.setattr(ens, "datetime", _at(10, 0)) + monkeypatch.setattr(settings, "DAY_TRADE_ORB_FULL_RANGE", True) + monkeypatch.setattr(ens, "_orb_finalized", set()) + ens._candle_cache["200001"] = (_t.monotonic(), _orb_day()) + assert ens._backfill_orb_from_cache_v2(["200001"]) == 1 + orb = day_trading_state.get_orb("200001") + assert orb.high == 10_000.0 + 10 + 29 + + +@pytest.mark.asyncio +async def test_restart_backfill_orb_matches_live_first_bars(monkeypatch): + """[R2-2] 09:30 이후 재기동 소급(collect_opening_ranges)도 평상시 첫 수집과 같은 ORB. + + 평상시: 09:04 사이클에 봉 N개 → 첫 성공으로 고정. 재기동: 하루치 분봉 전체. + 두 값이 같아야 한다(FULL_RANGE=False). + """ + from app.services.daytrading import entry_service as es + monkeypatch.setattr(settings, "DAY_TRADE_ORB_FULL_RANGE", False) + # [R6] 첫 N봉이 완성된 시각이어야 확정된다 — 벽시계에 의존하지 않게 고정 + monkeypatch.setattr(ens, "datetime", _at(10, 0)) + monkeypatch.setattr(es.asyncio, "sleep", AsyncMock(return_value=None)) + monkeypatch.setattr(day_trading_state, "load_orb_cache", AsyncMock(return_value=0)) + monkeypatch.setattr(day_trading_state, "save_orb_cache", AsyncMock(return_value=True)) + full_day = _orb_day(count=90) + live = full_day[: ens._ORB_FIRST_BARS] + feeds = {"300001": live, "300002": full_day} + monkeypatch.setattr(es, "parse_minute_candles", lambda raw: raw) + monkeypatch.setattr(es.korea_investment_api, "get_minute_chart_full_day", + AsyncMock(side_effect=lambda code: feeds[code])) + ens.set_watchlist([{"code": "300001", "name": "A"}, {"code": "300002", "name": "B"}]) + await es.collect_opening_ranges() + a, b = day_trading_state.get_orb("300001"), day_trading_state.get_orb("300002") + assert a is not None and b is not None + assert (a.high, a.low, a.total_volume, a.avg_vol_per_min, a.open_price) == \ + (b.high, b.low, b.total_volume, b.avg_vol_per_min, b.open_price) + + +def test_orb_backfill_skips_before_window_and_partial_coverage(monkeypatch): + import time as _t + monkeypatch.setattr(settings, "DAY_TRADE_ORB_FULL_RANGE", False) + # 09:20 — 레인지 창이 끝나지 않았다 + monkeypatch.setattr(ens, "datetime", _at(9, 20)) + ens._candle_cache["200001"] = (_t.monotonic(), _orb_day()) + assert ens._backfill_orb_from_cache_v2(["200001"]) == 0 + # 10:00 이지만 당일 분봉이 09:10 부터만 있다(페이지 누락) → 잘린 레인지로 확정하지 않는다 + monkeypatch.setattr(ens, "datetime", _at(10, 0)) + ens._candle_cache["200002"] = (_t.monotonic(), _orb_day(start_hhmm=(9, 10))) + assert ens._backfill_orb_from_cache_v2(["200002"]) == 0 + assert not day_trading_state.has_orb("200002") + # 이미 ORB 가 있으면 덮어쓰지 않는다 + sentinel = object() + day_trading_state.set_orb("200003", sentinel) + ens._candle_cache["200003"] = (_t.monotonic(), _orb_day()) + assert ens._backfill_orb_from_cache_v2(["200003"]) == 0 + assert day_trading_state.get_orb("200003") is sentinel + + +# ═════════════════════════════════════════════════════════════════════════════ +# 2. 타전략 보유 종목 제외 +# ═════════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_watchlist_excludes_non_dt_holdings_and_still_fills_cap(): + engine, Session = await _mk_session() + async with Session() as s: + s.add_all([_sa(f"30000{i}", 10 - i) for i in range(6)]) + s.add_all([ + # 비-DT: 제외 대상 + ActiveTrade(stock_code="300000", quantity=3, entry_price=1, status="active", + strategy_name="synced"), + ActiveTrade(stock_code="300001", quantity=3, entry_price=1, status="active", + strategy_name=None), + Portfolio(stock_code="300002", quantity=5, strategy_type="swing"), + # DT 소유·종료 행: 제외 아님 + ActiveTrade(stock_code="300003", quantity=3, entry_price=1, status="active", + strategy_name="day_trade"), + ActiveTrade(stock_code="300004", quantity=3, entry_price=1, status="closed", + strategy_name="swing"), + Portfolio(stock_code="300005", quantity=5, strategy_type="daytrading"), + ]) + await s.commit() + + with patch("app.core.database.AsyncSessionLocal", Session): + n = await ens.populate_daytrading_watchlist(min_score=1, max_stocks=3) + wl = ens.get_watchlist() + assert n == 3, "제외분만큼 더 읽어 상한을 채워야 한다" + assert set(wl) == {"300003", "300004", "300005"} + assert ens._non_dt_held_db == {"300000", "300001", "300002"} + await engine.dispose() + + +@pytest.mark.asyncio +async def test_held_snapshot_failure_keeps_previous_snapshot(monkeypatch): + """스냅샷 조회 실패 시 비우지 않는다(비우면 가드가 조용히 풀린다).""" + engine, Session = await _mk_session(with_holdings=False) # 테이블 없음 → 조회 예외 + monkeypatch.setattr(ens, "_non_dt_held_db", {"300000"}) + with patch("app.core.database.AsyncSessionLocal", Session): + await ens._refresh_non_dt_held_db_v2() + assert ens._non_dt_held_db == {"300000"} + await engine.dispose() + + +def test_price_filter_uses_settings(monkeypatch): + monkeypatch.setattr(settings, "MIN_PRICE_KRW", 8000) + assert ens._price_filter_block_v2("일반주", 8000.0) is not None # '초과'만 허용 + assert ens._price_filter_block_v2("일반주", 8001.0) is None + assert ens._price_filter_block_v2("KODEX 200", 3000.0) is None # ETF 예외 + + +@pytest.mark.asyncio +async def test_watchlist_price_filter_from_settings(monkeypatch): + engine, Session = await _mk_session() + monkeypatch.setattr(settings, "MIN_PRICE_KRW", 8000) + async with Session() as s: + s.add_all([_sa("400001", 9, price=7000), _sa("400002", 8, price=9000)]) + await s.commit() + with patch("app.core.database.AsyncSessionLocal", Session): + await ens.populate_daytrading_watchlist(min_score=1, max_stocks=5) + assert set(ens.get_watchlist()) == {"400002"} + await engine.dispose() + + +class _Adp: + fallback_used = False + daytrading_enabled = True + trading_mode = "normal" + + +async def _run_scan(monkeypatch, balance, captured): + async def fake_try_enter(code, name, total_assets, available_cash, is_lunch_break=False): + captured.append(code) + return None + + monkeypatch.setattr(ens, "datetime", _at(10, 30)) + with patch.object(ens.mdd_manager, "is_short_term_strategy_enabled", AsyncMock(return_value=True)), \ + patch.object(ens.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)), \ + patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()), \ + patch.object(ens, "is_daytrading_allowed_by_playbook", return_value=True), \ + patch.object(ens, "check_market_regime", AsyncMock(return_value={"regime": "neutral"})), \ + patch.object(ens.korea_investment_api, "get_balance", AsyncMock(return_value=balance)), \ + patch.object(ens.korea_investment_api, "get_buyable_cash", AsyncMock(return_value=CASH)), \ + patch.object(ens, "_prefetch_minute_charts", AsyncMock(return_value=0)), \ + patch.object(ens, "_try_enter", fake_try_enter): + return await ens.scan_and_enter() + + +@pytest.mark.asyncio +async def test_scan_excludes_kis_held_non_dt_stock(monkeypatch): + """KIS 잔고에 있으나 DT 인메모리 포지션이 아닌 종목(수동 목록 포함)은 스캔 후보에서 빠진다.""" + ens.set_watchlist([{"code": "105560", "name": "KB"}, {"code": "005930", "name": "삼성"}]) + balance = { + "output1": [{"pdno": "105560", "hldg_qty": "3", "prdt_name": "KB금융", "evlu_amt": "300000"}], + "output2": [{"tot_evlu_amt": str(TOTAL), "prvs_rcdl_excc_amt": str(CASH)}], + } + captured: list = [] + await _run_scan(monkeypatch, balance, captured) + assert captured == ["005930"] + assert ens._kis_holdings_verified is True + + +@pytest.mark.asyncio +async def test_scan_db_snapshot_excludes_even_without_output1(monkeypatch): + ens.set_watchlist([{"code": "105560", "name": "KB"}, {"code": "005930", "name": "삼성"}]) + monkeypatch.setattr(ens, "_non_dt_held_db", {"105560"}) + balance = {"output2": [{"tot_evlu_amt": str(TOTAL), "prvs_rcdl_excc_amt": str(CASH)}]} + captured: list = [] + await _run_scan(monkeypatch, balance, captured) + assert captured == ["005930"] + # output1 이 없으면 보유목록 검증 불가 → 레버리지 검증도 불가 상태로 표시 + assert ens._kis_holdings_verified is False + + +# ═════════════════════════════════════════════════════════════════════════════ +# _try_enter 가드 (주문 공통 종단 도달 여부로 판정) +# ═════════════════════════════════════════════════════════════════════════════ + +@pytest.fixture +def entry_env(monkeypatch): + import app.api.endpoints.mcp as mcp_mod + import app.services.trade_executor as te + + state = {"price": settings.MIN_PRICE_KRW * 2.0} + + def _candles(): + return [SimpleNamespace(close=state["price"], date="", time="100000") for _ in range(6)] + + def _rsi_bb(code, name, candles_5m, price): + return SimpleNamespace( + strategy="RSI_BB", confidence=0.7, + stop_loss=round(price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT), 2), + target_price=round(price * (1 + settings.DAY_TRADE_MIN_STOP_LOSS_PCT + * settings.DAY_TRADE_TARGET_MULTIPLIER), 2), + reason="test", + ) + + monkeypatch.setattr(ens.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) + monkeypatch.setattr(ens, "is_vi_active", lambda code: False) + monkeypatch.setattr(ens, "_get_candles_cached", AsyncMock(side_effect=lambda c: _candles())) + monkeypatch.setattr(ens, "filter_today_candles", lambda c: c) + monkeypatch.setattr(ens, "aggregate_to_5min", lambda c: c) + monkeypatch.setattr(ens, "calculate_vwap", lambda c: None) + monkeypatch.setattr(ens, "check_rsi_bb_signal", _rsi_bb) + monkeypatch.setattr(mcp_mod, "request_mcp_risk_assessment", + AsyncMock(return_value=(True, "")), raising=False) + monkeypatch.setattr(ens, "_fetch_fresh_price_v2_impl", AsyncMock(return_value=None)) + monkeypatch.setattr(te, "safe_place_order", _order_reached) + return state + + +@pytest.mark.asyncio +async def test_try_enter_control_reaches_order(entry_env): + """양성 대조 — 가드에 걸리지 않으면 주문 종단에 도달한다(목 체인이 유효함을 증명).""" + with pytest.raises(OrderReached): + await ens._try_enter("500001", "일반주", TOTAL, CASH) + + +@pytest.mark.asyncio +async def test_try_enter_price_filter_blocks(entry_env): + entry_env["price"] = float(settings.MIN_PRICE_KRW) # '초과' 아님 + assert await ens._try_enter("500001", "일반주", TOTAL, CASH) is None + assert "500001" not in ens._pending_entries + + +@pytest.mark.asyncio +async def test_try_enter_rejects_non_dt_holding_before_order(entry_env, monkeypatch): + """스캔 이후(사이클 중) 스냅샷에 들어온 타전략 보유도 주문 직전 재검사에서 막는다.""" + monkeypatch.setattr(ens, "_non_dt_held_db", {"500001"}) + assert await ens._try_enter("500001", "일반주", TOTAL, CASH) is None + monkeypatch.setattr(ens, "_non_dt_held_db", set()) + monkeypatch.setattr(ens, "_kis_holdings_snapshot", + {"500001": {"qty": 3, "name": "일반주", "eval": 1.0}}) + assert await ens._try_enter("500001", "일반주", TOTAL, CASH) is None + assert "500001" not in ens._pending_entries + + +@pytest.mark.asyncio +async def test_try_enter_leverage_cap(entry_env, monkeypatch): + lev_name = "KODEX 레버리지" + # 다른 레버리지 ETF 를 한도 바로 아래까지 보유 → 이번 주문 포함 시 초과 + near_cap = TOTAL * settings.LEVERAGE_ETF_MAX_RATIO - 1.0 + monkeypatch.setattr(ens, "_kis_holdings_snapshot", { + "122630": {"qty": 1, "name": "KODEX 레버리지", "eval": near_cap}, + }) + assert await ens._try_enter("233740", lev_name, TOTAL, CASH) is None + + # 보유목록 검증 불가 → 레버리지 종목만 fail-closed + monkeypatch.setattr(ens, "_kis_holdings_snapshot", {}) + monkeypatch.setattr(ens, "_kis_holdings_verified", False) + assert await ens._try_enter("233740", lev_name, TOTAL, CASH) is None + with pytest.raises(OrderReached): + await ens._try_enter("500001", "일반주", TOTAL, CASH) + + # 여유가 있으면 통과 + monkeypatch.setattr(ens, "_kis_holdings_verified", True) + with pytest.raises(OrderReached): + await ens._try_enter("233740", lev_name, TOTAL, CASH) diff --git a/backend/tests/test_dt_entry_hardening_r3_20261009.py b/backend/tests/test_dt_entry_hardening_r3_20261009.py new file mode 100644 index 0000000..e840007 --- /dev/null +++ b/backend/tests/test_dt_entry_hardening_r3_20261009.py @@ -0,0 +1,265 @@ +"""DT 진입 하드닝 라운드 3 회귀 테스트 (2026-10-09). + +1. [LOW] DT ActiveTrade upsert payload 에 order_id 가 없어(운영 154/154 NULL) 재기동 복원 + 포지션의 ODNO 가 빈 값이던 결함 → payload 에 order_id 를 싣는다. +2. [LOW] 매수 0체결·부분체결 종결 보정이 인메모리·ActiveTrade 만 정리하고 Portfolio(reflect_buy) + 반영은 되돌리지 않아, sync 전까지 고아 감지가 수동확인 경보를 오발하던 결함 + → 'daytrading' 태그 행에 한해 미체결분을 되돌린다(하한 = DT 인메모리 잔량, reflect_sell 미사용). + +목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형. 주문 공통 종단(safe_place_order)은 +도달 시 예외. 기대값은 settings 에서 계산한다. +""" +import asyncio +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.models.models import ActiveTrade, Portfolio +from app.services.daytrading import entry_service as ens +from app.services.daytrading import fill_reconciler as fr +from app.services.daytrading.position_manager import _active_trade_row_to_position, day_trading_state + + +class MustNotBeCalled(BaseException): + """호출 금지 경로 도달 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +CODE = "777777" +ODNO = "0000456" + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch): + import app.services.trade_executor as te + saved = dict(day_trading_state.positions) + saved_closed = set(day_trading_state.closed_today) + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + ens._pending_entries.clear() + monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order")) + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)): + yield + day_trading_state.positions.clear() + day_trading_state.positions.update(saved) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved_closed) + ens._pending_entries.clear() + + +def _pos(qty=10, order_id=ODNO): + return { + "stock_name": "테스트", "strategy": "VWAP_BOUNCE", "entry_price": 10_000.0, "quantity": qty, + "stop_loss": 9_700.0, "target_price": 10_450.0, "highest_price": 10_000.0, + "entry_time": datetime.now(), "reason": "", "order_id": order_id, + } + + +# ═════════════════════════════════════════════════════════════════════════════ +# 1. ActiveTrade order_id 영속화 +# ═════════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_accepted_entry_persists_order_id_to_active_trade(monkeypatch): + import app.services.portfolio_cache_bridge as pcb + upserts, scheduled = [], [] + + async def _ups(p): + upserts.append(p) + + monkeypatch.setattr(ens, "enqueue_trade_history", AsyncMock()) + monkeypatch.setattr(ens, "enqueue_active_trade_upsert", _ups) + monkeypatch.setattr(pcb, "reflect_buy", AsyncMock()) + monkeypatch.setattr(ens, "_schedule_buy_fill_reconcile", lambda *a, **k: scheduled.append(a)) + signal = SimpleNamespace(strategy="RSI_BB", stop_loss=9_700.0, target_price=10_450.0, reason="t") + + await ens._record_accepted_entry_impl(CODE, "테스트", signal, 10, 10_000.0, ODNO) + + assert len(upserts) == 1 + assert upserts[0]["order_id"] == ODNO + assert upserts[0]["strategy_name"] == "day_trade" + # db_worker 신규 생성 경로(ActiveTrade(**create_data))가 받아들이는 키인지 — ORM 컬럼 존재 확인 + row = ActiveTrade(**upserts[0]) + assert row.order_id == ODNO + assert scheduled and scheduled[0][1] == ODNO + + +def test_restore_round_trip_keeps_order_id(): + """복원 변환이 영속화된 ODNO 를 그대로 인메모리 포지션에 싣는다(빈 값 아님).""" + row = SimpleNamespace( + stock_code=CODE, stock_name="테스트", strategy_name="day_trade", entry_price=10_000.0, + stop_loss=9_700.0, take_profit=10_450.0, quantity=10, highest_since_entry=None, + created_at=datetime.now(), entry_reason="r", order_id=ODNO, remaining_pct=1.0, + ) + pos = _active_trade_row_to_position(row) + assert pos["order_id"] == ODNO + + +# ═════════════════════════════════════════════════════════════════════════════ +# 2. 0체결·부분체결 종결 시 Portfolio 되돌림 +# ═════════════════════════════════════════════════════════════════════════════ + +class _FakeCache: + def __init__(self, holdings): + self.holdings = holdings + self.apply_sell = AsyncMock() + + def note_sell(self, *a, **k): + raise MustNotBeCalled("note_sell — 매수 취소를 매도로 기록하면 sync 가 실보유 행을 지울 수 있다") + + +def _row(qty, tag="daytrading"): + return Portfolio(stock_code=CODE, stock_name="테스트", quantity=qty, strategy_type=tag, + average_price=10_000, current_price=10_000) + + +@pytest.fixture +def fill_env(monkeypatch): + import app.services.portfolio_cache_bridge as pcb + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True) + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0) + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0) + cap = {"upserts": [], "closes": [], "updates": [], "portfolio": []} + + async def _ups(p): + cap["upserts"].append(p) + + async def _close(code, strategy_name=None): + cap["closes"].append((code, strategy_name)) + + async def _upd(oid, f): + cap["updates"].append((oid, f)) + + async def _pf(p): + cap["portfolio"].append(p) + + monkeypatch.setattr(ens, "enqueue_active_trade_upsert", _ups) + monkeypatch.setattr("app.services.db_worker.enqueue_close_active_trade", _close) + monkeypatch.setattr("app.services.db_worker.enqueue_update_trade_history_by_order_id", _upd) + monkeypatch.setattr("app.services.db_worker.enqueue_portfolio_upsert", _pf) + # 매도 경로로 되돌리면 note_sell 이 sync 를 오도한다 — 도달 금지 + monkeypatch.setattr(pcb, "reflect_sell", _boom("reflect_sell")) + return cap + + +def _install_cache(monkeypatch, holdings, db_qty): + import app.services.portfolio_cache_bridge as pcb + cache = _FakeCache(holdings) + monkeypatch.setattr(pcb, "_cache", lambda: cache) + monkeypatch.setattr(pcb, "_db_quantity", AsyncMock(return_value=db_qty)) + return cache + + +async def _reconcile(rows, qty=10): + with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): + ens._schedule_buy_fill_reconcile(CODE, ODNO, 10_000.0, qty) + await asyncio.gather(*list(fr._pending_tasks)) + + +@pytest.mark.asyncio +async def test_zero_fill_terminal_reverts_portfolio_daytrading_row(fill_env, monkeypatch): + day_trading_state.positions[CODE] = _pos(qty=10) + cache = _install_cache(monkeypatch, [_row(10)], db_qty=10) + await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0", + "ord_qty": "10", "rmn_qty": "0"}]) + assert CODE not in day_trading_state.positions + assert fill_env["portfolio"] == [{"stock_code": CODE, "quantity": 0, "current_price": 10_000.0}] + assert cache.holdings == [] + cache.apply_sell.assert_awaited_once() + assert cache.apply_sell.await_args.args[:2] == (CODE, 10) + + +@pytest.mark.asyncio +async def test_zero_fill_terminal_keeps_reentry_and_restore_block(fill_env, monkeypatch): + """[R2-2 2026-10-09] 매수 0체결 종결 정리 뒤 재진입 차단(closed_today)과 당일 active 행 + 복원 차단(restore_block_reason_v2)이 둘 다 유지된다 — close 큐 지연 중 잔존 행이 + 인메모리로 되살아나 매도되지 않게. + """ + saved_exited = set(day_trading_state.dt_exited_today) + day_trading_state.dt_exited_today.discard(CODE) + try: + day_trading_state.positions[CODE] = _pos(qty=10) + _install_cache(monkeypatch, [_row(10)], db_qty=10) + await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0", + "ord_qty": "10", "rmn_qty": "0"}]) + assert CODE not in day_trading_state.positions + assert CODE in day_trading_state.closed_today + assert day_trading_state.restore_block_reason_v2(CODE) is not None + finally: + day_trading_state.dt_exited_today.clear() + day_trading_state.dt_exited_today.update(saved_exited) + + +@pytest.mark.asyncio +async def test_partial_fill_terminal_reverts_only_unfilled(fill_env, monkeypatch): + day_trading_state.positions[CODE] = _pos(qty=10) + cache = _install_cache(monkeypatch, [_row(10)], db_qty=10) + await _reconcile([{"odno": "456", "tot_ccld_qty": "6", "avg_prvs": "10030", + "ord_qty": "10", "rmn_qty": "0"}]) + assert day_trading_state.positions[CODE]["quantity"] == 6 + assert fill_env["portfolio"][-1]["quantity"] == 6 + assert cache.holdings[0].quantity == 6 + + +@pytest.mark.asyncio +async def test_partial_revert_never_below_dt_inmem_when_sync_already_corrected(fill_env, monkeypatch): + """sync 가 이미 실잔고(6)로 맞춘 뒤면 다시 빼지 않는다(하한 = DT 인메모리 6주).""" + day_trading_state.positions[CODE] = _pos(qty=10) + _install_cache(monkeypatch, [_row(6)], db_qty=6) + await _reconcile([{"odno": "456", "tot_ccld_qty": "6", "avg_prvs": "10030", + "ord_qty": "10", "rmn_qty": "0"}]) + assert fill_env["portfolio"][-1]["quantity"] == 6 + + +@pytest.mark.asyncio +async def test_mixed_strategy_row_is_not_touched(fill_env, monkeypatch): + """타 전략 태그 행은 스윙 보유분이 섞였을 수 있어 깎지 않는다(§3.5) — sync 에 맡긴다.""" + day_trading_state.positions[CODE] = _pos(qty=10) + cache = _install_cache(monkeypatch, [_row(13, tag="swing")], db_qty=13) + import app.services.portfolio_cache_bridge as pcb + monkeypatch.setattr(pcb, "_db_quantity", _boom("_db_quantity")) + await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0", + "ord_qty": "10", "rmn_qty": "0"}]) + assert CODE not in day_trading_state.positions + assert fill_env["portfolio"] == [] + assert cache.holdings[0].quantity == 13 + cache.apply_sell.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_replaced_position_zero_fill_does_not_revert(fill_env, monkeypatch): + """다른 주문의 포지션이면(교체·이미 청산) 원장만 보정 — Portfolio 는 건드리지 않는다.""" + day_trading_state.positions[CODE] = _pos(qty=10, order_id="OTHER") + _install_cache(monkeypatch, [_row(10)], db_qty=10) + await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0", + "ord_qty": "10", "rmn_qty": "0"}]) + assert day_trading_state.positions[CODE]["quantity"] == 10 + assert fill_env["portfolio"] == [] + + +@pytest.mark.asyncio +async def test_revert_helper_floor_and_no_increase(monkeypatch): + import app.services.portfolio_cache_bridge as pcb + out = [] + + async def _pf(p): + out.append(p) + + monkeypatch.setattr("app.services.db_worker.enqueue_portfolio_upsert", _pf) + _install_cache(monkeypatch, [_row(3)], db_qty=3) + # 미체결 10 > 행 3, 하한 0 → 0 (음수 아님) + assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 10, 10_000.0, 0) == 0 + _install_cache(monkeypatch, [_row(3)], db_qty=3) + # 하한이 행보다 커도 행 수량을 늘리지 않는다 + assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 2, 10_000.0, 9) == 3 + # 미체결 0 이면 아무것도 하지 않는다 + monkeypatch.setattr(pcb, "_cache", _boom("_cache")) + assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 0, 10_000.0, 0) is None diff --git a/backend/tests/test_dt_entry_hardening_r5_20261009.py b/backend/tests/test_dt_entry_hardening_r5_20261009.py new file mode 100644 index 0000000..82ed7d3 --- /dev/null +++ b/backend/tests/test_dt_entry_hardening_r5_20261009.py @@ -0,0 +1,153 @@ +"""DT 진입 하드닝 라운드 5 회귀 테스트 (2026-10-09). + +1. [MEDIUM] 전 전략 합산 현금 사용 상한(MAX_TOTAL_CAPITAL_USAGE)을 DT 가 주문 전 사용률로만 + 판정하고 `_clamp_day_trade_budget` 은 현금 전액까지 허용 → 주문 후 사용률이 100% 까지 감. + → 클램프에 "주문 후 사용률 ≤ 상한" 현금 여유(`_total_usage_cash_room_v2`)를 추가. +2. [LOW] 감시종목 선정 정렬이 score 단일 키라 동점군에서 비결정적 + → score desc, 거래대금 desc(NULL 마지막), stock_code asc. + +기대값은 settings 에서 계산한다(§4.1). 실 DB·KIS 미사용(SQLite 인메모리). +""" +from datetime import datetime + +import pytest +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.compiler import compiles +from unittest.mock import patch + +from app.core.config import settings +from app.models.models import ActiveTrade, Base, Portfolio, StockAnalysis +from app.services.daytrading import entry_service as ens +from app.services.daytrading.position_manager import day_trading_state + + +@compiles(JSONB, "sqlite") +def _jsonb_as_json(type_, compiler, **kw): # 테스트용 SQLite 호환 + return "JSON" + + +TOTAL = 100_000_000.0 + + +def _usage_after(total, cash, budget): + return (total - cash + budget) / total + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. 95% 합산 상한 — 주문 후 기준 +# ───────────────────────────────────────────────────────────────────────────── + +def test_clamp_keeps_total_usage_reserve_after_order(): + """스윙 ~70% + DT 투입 후 남은 현금이 유보분 근처면 주문 후 사용률이 상한을 넘지 않게 깎는다.""" + reserve = TOTAL * (1 - settings.MAX_TOTAL_CAPITAL_USAGE) + per_pos = TOTAL * settings.DAY_TRADE_MAX_CAPITAL_RATIO + # 가용현금 = 유보 + 종목당 한도의 절반 → 예전엔 종목당 한도까지 허용됐다 + cash = reserve + per_pos / 2 + budget = ens._clamp_day_trade_budget( + per_pos * 1.2, total_assets=TOTAL, available_cash=cash, current_day_trade_amount=0.0, + ) + assert budget == pytest.approx(per_pos / 2) + assert _usage_after(TOTAL, cash, budget) <= settings.MAX_TOTAL_CAPITAL_USAGE + 1e-9 + + +def test_clamp_zero_when_cash_within_reserve(): + """가용현금이 유보분 이하이면 예산 0(진입 불가) — 예전엔 현금 전액 허용.""" + reserve = TOTAL * (1 - settings.MAX_TOTAL_CAPITAL_USAGE) + budget = ens._clamp_day_trade_budget( + TOTAL, total_assets=TOTAL, available_cash=reserve * 0.9, current_day_trade_amount=0.0, + ) + assert budget == 0.0 + + +def test_clamp_unchanged_when_cash_ample(): + """현금이 충분하면 기존 세 한도(종목당·DT 잔여·현금) 결과 그대로 — 거래 축소 없음.""" + per_pos = TOTAL * settings.DAY_TRADE_MAX_CAPITAL_RATIO + budget = ens._clamp_day_trade_budget( + per_pos * 1.2, total_assets=TOTAL, available_cash=TOTAL * 0.5, current_day_trade_amount=0.0, + ) + assert budget == pytest.approx(per_pos) + + +def test_cash_room_reserve_ratio_clamped(monkeypatch): + """상한이 1 초과로 잘못 설정돼도 현금 이상을 허용하지 않는다(유보 하한 0).""" + monkeypatch.setattr(settings, "MAX_TOTAL_CAPITAL_USAGE", 1.5) + assert ens._total_usage_cash_room_v2(TOTAL, 1_000_000.0) == pytest.approx(1_000_000.0) + monkeypatch.setattr(settings, "MAX_TOTAL_CAPITAL_USAGE", -0.1) + assert ens._total_usage_cash_room_v2(TOTAL, 1_000_000.0) == 0.0 + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. 감시종목 결정적 정렬 +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.fixture +def _isolate(monkeypatch): + saved_orb = dict(day_trading_state.orb_cache) + day_trading_state.orb_cache.clear() + monkeypatch.setattr(ens, "_non_dt_held_db", set()) + + async def _noop(): + return None + monkeypatch.setattr(ens, "_refresh_non_dt_held_db_v2", _noop) + ens.set_watchlist([]) + yield + day_trading_state.orb_cache.clear() + day_trading_state.orb_cache.update(saved_orb) + ens.set_watchlist([]) + + +def _today(): + return datetime.now(ens._KST).date() + + +def _sa(code, score, volume, price=None): + return StockAnalysis( + stock_code=code, stock_name=f"N{code}", analysis_date=_today(), score=score, + rsi=40.0, volume=volume, + current_price=price if price is not None else int(settings.MIN_PRICE_KRW * 2), + ) + + +async def _run(rows, max_stocks): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync( + Base.metadata.create_all, + tables=[StockAnalysis.__table__, ActiveTrade.__table__, Portfolio.__table__], + ) + Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with Session() as s: + s.add_all(rows) + await s.commit() + try: + with patch("app.core.database.AsyncSessionLocal", Session): + n = await ens.populate_daytrading_watchlist(min_score=1, max_stocks=max_stocks) + return n, list(ens.get_watchlist()) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_watchlist_ties_broken_by_turnover_then_code(_isolate): + """동점(score=3)군에서 거래대금 큰 순 → NULL 마지막 → 코드 오름차순으로 결정적으로 고른다.""" + rows = [ + _sa("000009", 3, None), + _sa("000005", 3, 1_000), + _sa("000001", 3, 1_000), # 거래대금 동점 → 코드 오름차순 + _sa("000007", 3, 9_000), + _sa("000099", 5, 1), # 점수 우선 + ] + n, wl = await _run(rows, max_stocks=4) + assert n == 4 + assert wl == ["000099", "000007", "000001", "000005"] + + +@pytest.mark.asyncio +async def test_watchlist_selection_independent_of_insert_order(_isolate): + """삽입(물리) 순서를 뒤집어도 같은 구성원이 선정된다.""" + base = [(f"1000{i:02d}", 3, 1_000 * (i % 3)) for i in range(12)] + n1, wl1 = await _run([_sa(*r) for r in base], max_stocks=5) + n2, wl2 = await _run([_sa(*r) for r in reversed(base)], max_stocks=5) + assert n1 == n2 == 5 + assert wl1 == wl2 diff --git a/backend/tests/test_dt_entry_portfolio_caps_r1_20261009.py b/backend/tests/test_dt_entry_portfolio_caps_r1_20261009.py new file mode 100644 index 0000000..3a98c73 --- /dev/null +++ b/backend/tests/test_dt_entry_portfolio_caps_r1_20261009.py @@ -0,0 +1,195 @@ +"""DT 진입 §5.1 섹터캡·최대 보유 종목 수 회귀 테스트 (하드닝 루프 R1, 2026-10-09). + +결함: DT 진입 경로가 섹터당 상한·MAX_HOLDINGS_COUNT 를 보지 않아 같은 섹터 5종목, +스윙 20 + DT 5 = 25종목 보유가 가능했다. 플래그(DAY_TRADE_SECTOR_CAP_ENABLED / +DAY_TRADE_HOLDINGS_CAP_ENABLED, 기본 OFF)로 출시 — OFF 면 현행 동작 그대로. + +목킹 원칙(§4.1): 도달하면 안 되는 주문은 공통 종단(trade_executor.safe_place_order)에 +BaseException 발생형으로 건다. 기대값은 settings / correlation_filter 기본값에서 계산한다. +""" +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.core.config import settings +from app.services import sector_mapper +from app.services.daytrading import entry_service as ens +from app.services.daytrading.position_manager import day_trading_state + +TOTAL = 100_000_000.0 +CASH = 50_000_000.0 +TARGET = "500001" + + +class OrderReached(BaseException): + """주문 공통 종단 도달 (except Exception 에 삼켜지지 않게 BaseException).""" + + +async def _order_reached(*a, **k): + raise OrderReached("safe_place_order 도달") + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch): + saved_pos = dict(day_trading_state.positions) + saved_closed = set(day_trading_state.closed_today) + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + ens._pending_entries.clear() + monkeypatch.setattr(ens, "_non_dt_held_db", set()) + monkeypatch.setattr(ens, "_kis_holdings_snapshot", {}) + monkeypatch.setattr(ens, "_kis_holdings_verified", True) + monkeypatch.setattr(settings, "SECTOR_CAP_UNMAPPED_ENABLED", False, raising=False) + yield + day_trading_state.positions.clear() + day_trading_state.positions.update(saved_pos) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved_closed) + ens._pending_entries.clear() + + +@pytest.fixture +def entry_env(monkeypatch): + import app.api.endpoints.mcp as mcp_mod + import app.services.trade_executor as te + + price = settings.MIN_PRICE_KRW * 2.0 + + def _candles(): + return [SimpleNamespace(close=price, date="", time="100000") for _ in range(6)] + + def _rsi_bb(code, name, candles_5m, p): + return SimpleNamespace( + strategy="RSI_BB", confidence=0.7, + stop_loss=round(p * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT), 2), + target_price=round(p * (1 + settings.DAY_TRADE_MIN_STOP_LOSS_PCT + * settings.DAY_TRADE_TARGET_MULTIPLIER), 2), + reason="test", + ) + + monkeypatch.setattr(ens.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) + monkeypatch.setattr(ens, "is_vi_active", lambda code: False) + monkeypatch.setattr(ens, "_get_candles_cached", AsyncMock(side_effect=lambda c: _candles())) + monkeypatch.setattr(ens, "filter_today_candles", lambda c: c) + monkeypatch.setattr(ens, "aggregate_to_5min", lambda c: c) + monkeypatch.setattr(ens, "calculate_vwap", lambda c: None) + monkeypatch.setattr(ens, "check_rsi_bb_signal", _rsi_bb) + monkeypatch.setattr(mcp_mod, "request_mcp_risk_assessment", + AsyncMock(return_value=(True, "")), raising=False) + monkeypatch.setattr(ens, "_fetch_fresh_price_v2_impl", AsyncMock(return_value=None)) + monkeypatch.setattr(te, "safe_place_order", _order_reached) + + +def _set_flags(monkeypatch, sector: bool, holdings: bool): + # config.py 필드가 아직 없을 수 있어(pydantic 은 미정의 속성 setattr 거부) 플래그 읽기 지점을 덮는다 + monkeypatch.setattr(ens, "_dt_cap_flags_v2", lambda: (sector, holdings)) + + +def _same_sector_holdings(monkeypatch, n: int): + """TARGET 과 같은 섹터 보유 n종목을 KIS 스냅샷에 넣고 섹터 매핑을 고정한다.""" + held = {f"6000{i:02d}": {"qty": 1, "name": f"H{i}", "eval": 1.0} for i in range(n)} + monkeypatch.setattr(ens, "_kis_holdings_snapshot", held) + mapping = {TARGET: "semiconductor", **{c: "semiconductor" for c in held}} + monkeypatch.setattr(sector_mapper, "get_sector", lambda code: mapping.get(code)) + return held + + +def test_default_flags_off(): + """config.py 에 필드가 없거나 False 면 기본 OFF(현행 동작 유지).""" + expected = ( + bool(getattr(settings, "DAY_TRADE_SECTOR_CAP_ENABLED", False)), + bool(getattr(settings, "DAY_TRADE_HOLDINGS_CAP_ENABLED", False)), + ) + assert ens._dt_cap_flags_v2() == expected + if expected == (False, False): + assert ens._portfolio_cap_block_v2(TARGET) is None + + +def test_sector_limit_single_source(): + from app.services.correlation_filter import validate_correlation_filter + import inspect + assert ens._dt_sector_cap_limit_v2() == \ + inspect.signature(validate_correlation_filter).parameters["max_per_sector"].default + + +@pytest.mark.asyncio +async def test_flags_off_keeps_current_behavior(entry_env, monkeypatch): + """기본 OFF — 같은 섹터가 상한만큼 있어도 현행대로 주문 종단에 도달한다.""" + _set_flags(monkeypatch, False, False) + _same_sector_holdings(monkeypatch, ens._dt_sector_cap_limit_v2()) + with pytest.raises(OrderReached): + await ens._try_enter(TARGET, "일반주", TOTAL, CASH) + + +@pytest.mark.asyncio +async def test_sector_cap_blocks_when_enabled(entry_env, monkeypatch): + _set_flags(monkeypatch, True, False) + cap = ens._dt_sector_cap_limit_v2() + _same_sector_holdings(monkeypatch, cap) + assert await ens._try_enter(TARGET, "일반주", TOTAL, CASH) is None + assert TARGET not in ens._pending_entries + + # 상한 미만이면 통과(양성 대조) + _same_sector_holdings(monkeypatch, cap - 1) + with pytest.raises(OrderReached): + await ens._try_enter(TARGET, "일반주", TOTAL, CASH) + + +@pytest.mark.asyncio +async def test_sector_cap_counts_dt_positions_and_pending(entry_env, monkeypatch): + """DT 인메모리·주문 진행 중 종목도 섹터 카운트에 들어간다(DT 5종목 쏠림 방지).""" + _set_flags(monkeypatch, True, False) + cap = ens._dt_sector_cap_limit_v2() + codes = [f"7000{i:02d}" for i in range(cap)] + mapping = {TARGET: "battery", **{c: "battery" for c in codes}} + monkeypatch.setattr(sector_mapper, "get_sector", lambda code: mapping.get(code)) + for c in codes[:-1]: + day_trading_state.positions[c] = {"entry_price": 1.0, "quantity": 1} + ens._pending_entries[codes[-1]] = 1.0 + assert ens._portfolio_cap_block_v2(TARGET) is not None + + +def test_unmapped_follows_sector_cap_unmapped_flag(monkeypatch): + _set_flags(monkeypatch, True, False) + cap = ens._dt_sector_cap_limit_v2() + held = {f"8000{i:02d}": {"qty": 1, "name": "x", "eval": 1.0} for i in range(cap)} + monkeypatch.setattr(ens, "_kis_holdings_snapshot", held) + monkeypatch.setattr(sector_mapper, "get_sector", lambda code: None) + assert ens._portfolio_cap_block_v2(TARGET) is None + monkeypatch.setattr(settings, "SECTOR_CAP_UNMAPPED_ENABLED", True, raising=False) + assert ens._portfolio_cap_block_v2(TARGET) is not None + + +@pytest.mark.asyncio +async def test_holdings_cap_blocks_when_enabled(entry_env, monkeypatch): + _set_flags(monkeypatch, False, True) + n = settings.MAX_HOLDINGS_COUNT + held = {f"9{i:05d}": {"qty": 1, "name": "x", "eval": 1.0} for i in range(n)} + monkeypatch.setattr(ens, "_kis_holdings_snapshot", held) + assert await ens._try_enter(TARGET, "일반주", TOTAL, CASH) is None + + # 비-DT DB 스냅샷과 KIS 스냅샷 합집합으로 센다(첫 페이지 누락 보완) + first = dict(list(held.items())[: n - 1]) + monkeypatch.setattr(ens, "_kis_holdings_snapshot", first) + assert ens._portfolio_cap_block_v2(TARGET) is None + monkeypatch.setattr(ens, "_non_dt_held_db", {"999999"}) + assert ens._portfolio_cap_block_v2(TARGET) is not None + + +def test_fail_closed_when_holdings_unverified(monkeypatch): + _set_flags(monkeypatch, True, False) + monkeypatch.setattr(ens, "_kis_holdings_verified", False) + assert ens._portfolio_cap_block_v2(TARGET) is not None + _set_flags(monkeypatch, False, False) + assert ens._portfolio_cap_block_v2(TARGET) is None + + +def test_sector_lookup_error_fails_closed(monkeypatch): + _set_flags(monkeypatch, True, False) + + def _boom(code): + raise RuntimeError("cache corrupt") + + monkeypatch.setattr(sector_mapper, "get_sector", _boom) + assert ens._portfolio_cap_block_v2(TARGET) is not None diff --git a/backend/tests/test_dt_entry_unknown_and_fresh_price_20261009.py b/backend/tests/test_dt_entry_unknown_and_fresh_price_20261009.py new file mode 100644 index 0000000..fa855ac --- /dev/null +++ b/backend/tests/test_dt_entry_unknown_and_fresh_price_20261009.py @@ -0,0 +1,483 @@ +"""DT 진입 하드닝 라운드 1 회귀 테스트 (2026-10-09). + +R1-1 [HIGH] 매수 주문 결과 불명(KIS_TRANSPORT_UNKNOWN·응답 없음·래퍼 밖 예외)을 '실패'로 + 처리하던 결함 — 재진입 차단·예약 유지 후 주문내역·잔고로 판정. +R1-2 [LOW] 진입가가 선조회 분봉 종가(최대 수 분 묵음) — 주문 직전 현재가로 같은 신호 함수를 + 재판정하고 손절·목표·수량을 신선가 기준으로 교체. + +목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형, 공통 종단(safe_place_order)에 +건다. 기대값은 settings·사이징 함수에서 계산한다(하드코딩 금지). +""" +import asyncio +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.daytrading import entry_service as ens +from app.services.daytrading import exit_service as es +from app.services.daytrading.position_manager import day_trading_state + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +CODE = "111111" +TOTAL = 100_000_000.0 +CASH = 50_000_000.0 +CANDLE_PRICE = 10_000.0 +# 픽스처가 목으로 덮기 전의 실제 구현(조회 계층만 목킹해 검증한다) +_real_fetch_fresh = ens._fetch_fresh_price_v2_impl + + +@pytest.fixture(autouse=True) +def _isolate_state(monkeypatch): + saved = dict(day_trading_state.positions) + saved_closed = set(day_trading_state.closed_today) + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.daily_pnl = 0.0 + ens._pending_entries.clear() + ens._candle_cache.clear() + ens._unknown_buy_entries.clear() + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0) + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0) + # [R4] 재판정 라운드·확정 후 감시 — 간격 0, 감시는 기본 끔(감시 테스트가 켠다) + # (settings 키가 아직 없어 모듈 폴백값을 덮는다 — 키가 생기면 settings 쪽이 우선) + for _k, _v in (("RESOLVE_RETRY_SEC", 0.0), ("WATCH_CHECKS", 0), ("WATCH_INTERVAL_SEC", 0.0)): + monkeypatch.setattr(ens, f"_UNKNOWN_BUY_{_k}_DEFAULT", _v) + if hasattr(settings, f"DAY_TRADE_ENTRY_UNKNOWN_{_k}"): + monkeypatch.setattr(settings, f"DAY_TRADE_ENTRY_UNKNOWN_{_k}", _v) + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)): + yield + day_trading_state.positions.clear() + day_trading_state.positions.update(saved) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved_closed) + ens._pending_entries.clear() + ens._unknown_buy_entries.clear() + + +def _make_signal(price: float, strategy: str = "RSI_BB"): + # 손절·목표가 '현재가 기준' 으로 계산됨을 흉내낸다(신호 함수의 계약) + return SimpleNamespace( + strategy=strategy, confidence=0.7, + stop_loss=round(price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT), 2), + target_price=round(price * (1 + settings.DAY_TRADE_MIN_STOP_LOSS_PCT * settings.DAY_TRADE_TARGET_MULTIPLIER), 2), + reason=f"test@{price}", + ) + + +@pytest.fixture +def entry_env(monkeypatch): + """_try_enter 를 신호까지 통과시키는 목. 주문·현재가는 테스트가 정한다.""" + import app.services.portfolio_cache_bridge as pcb + import app.api.endpoints.mcp as mcp_mod + + cap = {"history": [], "upserts": [], "signal_prices": [], "reconcile": []} + + async def _hist(p): + cap["history"].append(p) + + async def _ups(p): + cap["upserts"].append(p) + + def _rsi_bb(code, name, candles_5m, price): + cap["signal_prices"].append(price) + return _make_signal(price) + + candles = [SimpleNamespace(close=CANDLE_PRICE, date="", time="100000") for _ in range(6)] + monkeypatch.setattr(ens.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) + monkeypatch.setattr(ens, "is_vi_active", lambda code: False) + monkeypatch.setattr(ens, "_get_candles_cached", AsyncMock(return_value=candles)) + monkeypatch.setattr(ens, "filter_today_candles", lambda c: c) + monkeypatch.setattr(ens, "aggregate_to_5min", lambda c: c) + monkeypatch.setattr(ens, "calculate_vwap", lambda c: None) + monkeypatch.setattr(ens, "check_rsi_bb_signal", _rsi_bb) + monkeypatch.setattr(ens, "enqueue_trade_history", _hist) + monkeypatch.setattr(ens, "enqueue_active_trade_upsert", _ups) + monkeypatch.setattr(ens, "_schedule_buy_fill_reconcile", + lambda *a: cap["reconcile"].append(a)) + monkeypatch.setattr(pcb, "reflect_buy", AsyncMock()) + monkeypatch.setattr(mcp_mod, "request_mcp_risk_assessment", + AsyncMock(return_value=(True, "")), raising=False) + # 기본: 신선가 조회 실패 → 분봉가 경로. 신선가 테스트가 덮어쓴다. + monkeypatch.setattr(ens, "_fetch_fresh_price_v2_impl", AsyncMock(return_value=None)) + return cap + + +def _expected_qty(price: float) -> int: + b = ens.calculate_day_trade_position_size( + total_assets=TOTAL, available_cash=CASH, current_day_trade_amount=0.0, + ) + b = ens._clamp_day_trade_budget(b * min(1.2, 0.7 / 0.7), total_assets=TOTAL, + available_cash=CASH, current_day_trade_amount=0.0) + return int(b // price) + + +def _unknown_resp(): + return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout"} + + +# ═════════════════════════════════════════════════════════════════════════════ +# R1-1 결과 불명 매수 +# ═════════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +@pytest.mark.parametrize("resp", [ + _unknown_resp(), + None, # 응답 없음 + {"rt_cd": "-1", "msg1": "wrapped exception"}, # msg_cd 없는 래퍼 예외 +]) +async def test_unknown_buy_blocks_reentry_and_keeps_reservation(entry_env, monkeypatch, resp): + import app.services.trade_executor as te + resolver = AsyncMock(return_value="unresolved") + monkeypatch.setattr(ens, "_resolve_unknown_buy_v2_impl", resolver) + monkeypatch.setattr(te, "safe_place_order", AsyncMock(return_value=resp)) + + assert await ens._try_enter(CODE, "T", TOTAL, CASH) is None + await asyncio.sleep(0) + + assert CODE in day_trading_state.closed_today, "불명인데 당일 재진입 차단이 없다" + assert CODE in ens._pending_entries, "불명인데 슬롯·예약이 해제됐다(DT 30% 한도 초과 가능)" + assert CODE not in day_trading_state.positions + assert entry_env["history"] == [] and entry_env["upserts"] == [] + assert ens.get_unknown_buy_entries()[CODE]["qty"] == _expected_qty(CANDLE_PRICE) + resolver.assert_awaited() + + # 다음 사이클: 같은 종목 재주문 금지 + monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order(재진입)")) + assert await ens._try_enter(CODE, "T", TOTAL, CASH) is None + + +@pytest.mark.asyncio +async def test_exception_escaping_safe_place_order_is_unknown_not_rejected(entry_env, monkeypatch): + import app.services.trade_executor as te + monkeypatch.setattr(ens, "_resolve_unknown_buy_v2_impl", AsyncMock(return_value="unresolved")) + + async def _raise(**kw): + raise RuntimeError("connection reset after send") + + monkeypatch.setattr(te, "safe_place_order", _raise) + assert await ens._try_enter(CODE, "T", TOTAL, CASH) is None + assert CODE in day_trading_state.closed_today + assert CODE in ens._pending_entries + + +@pytest.mark.asyncio +@pytest.mark.parametrize("resp", [ + {"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE", "msg1": "x"}, # 전송 전 + {"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY", "msg1": "order_lock_busy"}, + {"rt_cd": "1", "msg_cd": "APBK0013", "msg1": "주문가능금액 초과"}, # KIS 업무 거부 +]) +async def test_rejected_buy_releases_reservation_without_block(entry_env, monkeypatch, resp): + import app.services.trade_executor as te + monkeypatch.setattr(ens, "_resolve_unknown_buy_v2_impl", _boom("_resolve_unknown_buy_v2_impl")) + monkeypatch.setattr(te, "safe_place_order", AsyncMock(return_value=resp)) + assert await ens._try_enter(CODE, "T", TOTAL, CASH) is None + assert CODE not in ens._pending_entries + assert CODE not in day_trading_state.closed_today + assert ens.get_unknown_buy_entries() == {} + + +async def _register(qty: int = 7, since: datetime | None = None, price: float = CANDLE_PRICE): + since = since or datetime.now(ens._KST).replace(tzinfo=None) + ens._pending_entries[CODE] = price * qty + with patch.object(ens, "_resolve_unknown_buy_v2_impl", AsyncMock(return_value="x")): + kept = await ens._register_unknown_buy_v2_impl( + CODE, "T", _make_signal(price), qty, price, since, _unknown_resp(), + ) + assert kept is True + return (ens._today_iso(), CODE), since + + +def _ccld(rows): + """주문내역 조회 성공(rt_cd=0) 응답 목 — get_todays_orders 가 아니라 strict 경로의 종단.""" + return AsyncMock(return_value=({"rt_cd": "0", "output1": rows}, None)) + + +def _buy_row(odno="0000123", qty=7, tmd=None, ccld=7, rjct=0, cncl="N", side="02"): + return { + "pdno": CODE, "odno": odno, "sll_buy_dvsn_cd": side, "ord_qty": str(qty), + "tot_ccld_qty": str(ccld), "rjct_qty": str(rjct), "cncl_yn": cncl, + "ord_tmd": tmd or datetime.now(ens._KST).strftime("%H%M%S"), + "avg_prvs": "10010", + } + + +@pytest.mark.asyncio +async def test_resolve_accepted_creates_dt_position_with_odno(entry_env, monkeypatch): + key, since = await _register(qty=7) + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", _ccld([_buy_row()])), \ + patch.object(es, "_fetch_kis_holdings_v2_impl", _boom("잔고(접수 확인 뒤 불필요)")): + assert await ens._resolve_unknown_buy_v2_impl(CODE, key) == "accepted" + + pos = day_trading_state.positions[CODE] + assert pos["order_id"] == "0000123" and pos["quantity"] == 7 + assert pos["entry_time"] == since, "시간손절 기준은 주문 시각이어야 한다" + assert CODE not in ens._pending_entries, "예약이 포지션으로 전환되지 않았다" + assert entry_env["history"][0]["order_id"] == "0000123" + assert entry_env["upserts"][0]["strategy_name"] == "day_trade" + assert entry_env["reconcile"] and entry_env["reconcile"][0][1] == "0000123" + assert ens.get_unknown_buy_entries()[CODE]["status"] == "accepted" + + +@pytest.mark.asyncio +async def test_resolve_not_accepted_when_no_row_and_zero_holdings(entry_env, monkeypatch): + key, _ = await _register(qty=7) + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", _ccld([])), \ + patch.object(es, "_fetch_kis_holdings_v2_impl", AsyncMock(return_value={})): + assert await ens._resolve_unknown_buy_v2_impl(CODE, key) == "not_accepted" + assert CODE not in ens._pending_entries, "미접수인데 예약이 남았다" + assert CODE in day_trading_state.closed_today, "당일 재진입 차단은 유지(보수)" + assert CODE not in day_trading_state.positions + + +@pytest.mark.asyncio +@pytest.mark.parametrize("holdings", [None, {CODE: {"hldg": 30, "sellable": 30}}]) +async def test_resolve_unresolved_keeps_reservation_and_alerts(entry_env, monkeypatch, holdings): + """잔고 조회 실패 / 같은 종목 보유(스윙과 구별 불가) + 주문내역 미확인 → 판단 불가.""" + key, _ = await _register(qty=7) + alerts = [] + monkeypatch.setattr(ens, "_alert_unknown_buy_manual_check", lambda *a: alerts.append(a)) + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", + AsyncMock(side_effect=RuntimeError("ccld down"))), \ + patch.object(es, "_fetch_kis_holdings_v2_impl", AsyncMock(return_value=holdings)): + assert await ens._resolve_unknown_buy_v2_impl(CODE, key) == "unresolved" + assert CODE in ens._pending_entries + assert CODE not in day_trading_state.positions + assert alerts, "수동 확인 경보가 없다" + + +@pytest.mark.asyncio +async def test_resolve_ignores_non_matching_rows(entry_env, monkeypatch): + """수량 불일치·매도·이전 시각·거부·이미 쓰는 ODNO 행은 접수 근거가 아니다.""" + key, since = await _register(qty=7) + day_trading_state.positions["222222"] = {"order_id": "0000555", "quantity": 1, "entry_price": 1.0} + old = (since - timedelta(seconds=settings.DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC + 120)).strftime("%H%M%S") + rows = [ + _buy_row(odno="1", qty=8), + _buy_row(odno="2", side="01"), + _buy_row(odno="3", tmd=old), + _buy_row(odno="4", ccld=0, rjct=7), + _buy_row(odno="0000555"), + ] + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", _ccld(rows)), \ + patch.object(es, "_fetch_kis_holdings_v2_impl", AsyncMock(return_value={})): + assert await ens._resolve_unknown_buy_v2_impl(CODE, key) == "not_accepted" + assert CODE not in day_trading_state.positions + + +# ── R4 (2026-10-09) 미접수 확정은 '주문내역 조회 성공 + 행 없음 + 잔고 0' 에서만 ────────── + +def _ccld_fail(): + return AsyncMock(return_value=({"rt_cd": "1", "msg_cd": "EGW00201", "output1": []}, None)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ccld", [ + "fail_rt_cd", + "exception", +]) +async def test_resolve_does_not_confirm_not_accepted_when_order_query_failed(entry_env, monkeypatch, ccld): + """조회 실패(빈 응답과 구별) + 잔고 0주 → 미접수 확정 금지(접수·미체결일 수 있다). + 라운드마다 재판정하고 끝내 불가면 예약 유지 + 경보.""" + key, _ = await _register(qty=7) + alerts = [] + monkeypatch.setattr(ens, "_alert_unknown_buy_manual_check", lambda *a: alerts.append(a)) + watch = [] + monkeypatch.setattr(ens, "_schedule_unknown_buy_watch_v2", lambda *a: watch.append(a)) + mock = _ccld_fail() if ccld == "fail_rt_cd" else AsyncMock(side_effect=RuntimeError("ccld down")) + holdings = AsyncMock(return_value={}) + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", mock), \ + patch.object(es, "_fetch_kis_holdings_v2_impl", holdings): + assert await ens._resolve_unknown_buy_v2_impl(CODE, key) == "unresolved" + rounds = ens._unknown_buy_timing_v2()[0] + assert mock.await_count == rounds * max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)), "재판정 라운드 미수행" + assert holdings.await_count == rounds + assert CODE in ens._pending_entries, "판정 불가인데 예약이 해제됐다" + assert ens.get_unknown_buy_entries()[CODE]["status"] == "unresolved" + assert alerts and not watch + + +@pytest.mark.asyncio +async def test_resolve_retries_round_and_accepts_late_row(entry_env, monkeypatch): + """1라운드 조회 실패 → 다음 라운드에서 접수 행 확인 → 포지션 등록.""" + monkeypatch.setattr(ens, "_UNKNOWN_BUY_RESOLVE_ROUNDS_DEFAULT", 2) + if hasattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_RESOLVE_ROUNDS"): + monkeypatch.setattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_RESOLVE_ROUNDS", 2) + key, _ = await _register(qty=7) + attempts = max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)) + fail = ({"rt_cd": "1", "output1": []}, None) + ok = ({"rt_cd": "0", "output1": [_buy_row(odno="0000777")]}, None) + mock = AsyncMock(side_effect=[fail] * attempts + [ok]) + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", mock), \ + patch.object(es, "_fetch_kis_holdings_v2_impl", AsyncMock(return_value={})): + assert await ens._resolve_unknown_buy_v2_impl(CODE, key) == "accepted" + assert day_trading_state.positions[CODE]["order_id"] == "0000777" + + +@pytest.mark.asyncio +async def test_not_accepted_schedules_watch_and_late_row_becomes_dt_position(entry_env, monkeypatch): + """미접수 확정 후 감시 창에서 주문 행이 나타나면 DT 포지션으로 등록(손절·EOD 관리 하).""" + key, since = await _register(qty=7) + watch = [] + monkeypatch.setattr(ens, "_schedule_unknown_buy_watch_v2", lambda *a: watch.append(a)) + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", _ccld([])), \ + patch.object(es, "_fetch_kis_holdings_v2_impl", AsyncMock(return_value={})): + assert await ens._resolve_unknown_buy_v2_impl(CODE, key) == "not_accepted" + assert watch == [(CODE, key)], "미접수 확정 후 감시가 예약되지 않았다" + + monkeypatch.setattr(ens, "_UNKNOWN_BUY_WATCH_CHECKS_DEFAULT", 2) + if hasattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_WATCH_CHECKS"): + monkeypatch.setattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_WATCH_CHECKS", 2) + alerts = [] + monkeypatch.setattr(ens, "_alert_unknown_buy_manual_check", lambda *a: alerts.append(a)) + mock = AsyncMock(side_effect=[({"rt_cd": "0", "output1": []}, None), + ({"rt_cd": "0", "output1": [_buy_row(odno="0000888", ccld=0)]}, None)]) + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", mock), \ + patch.object(es, "_fetch_kis_holdings_v2_impl", AsyncMock(return_value={})): + assert await ens._watch_released_unknown_buy_v2_impl(CODE, key) == "late_accepted" + pos = day_trading_state.positions[CODE] + assert pos["order_id"] == "0000888" and pos["entry_time"] == since + assert entry_env["upserts"][-1]["strategy_name"] == "day_trade" + assert entry_env["reconcile"][-1][1] == "0000888", "체결 0 종결 처리를 위한 체결 확인이 없다" + assert alerts, "늦은 접수 경보가 없다" + + +@pytest.mark.asyncio +async def test_watch_alerts_when_holding_appears_without_order_row(entry_env, monkeypatch): + """감시 중 주문 행 없이 잔고만 나타나면 경보만(스윙 매수와 구별 불가 — 임의 등록 금지).""" + key, _ = await _register(qty=7) + await ens._release_unknown_buy_reservation_impl(CODE, key, "not_accepted") + monkeypatch.setattr(ens, "_UNKNOWN_BUY_WATCH_CHECKS_DEFAULT", 3) + if hasattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_WATCH_CHECKS"): + monkeypatch.setattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_WATCH_CHECKS", 3) + alerts = [] + monkeypatch.setattr(ens, "_alert_unknown_buy_manual_check", lambda *a: alerts.append(a)) + holdings = AsyncMock(side_effect=[None, {CODE: {"hldg": 7, "sellable": 7}}]) + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", _ccld([])), \ + patch.object(es, "_fetch_kis_holdings_v2_impl", holdings): + assert await ens._watch_released_unknown_buy_v2_impl(CODE, key) == "holding_alert" + assert CODE not in day_trading_state.positions + assert alerts + + +@pytest.mark.asyncio +async def test_strict_order_fetch_separates_failure_from_empty(): + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", _ccld([])): + assert await ens._fetch_todays_orders_strict_v2_impl(CODE) == [] + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", _ccld_fail()): + assert await ens._fetch_todays_orders_strict_v2_impl(CODE) is None + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", + AsyncMock(side_effect=RuntimeError("x"))): + assert await ens._fetch_todays_orders_strict_v2_impl(CODE) is None + # 차단 예외(BaseException)는 삼키지 않는다 + with patch.object(ens.korea_investment_api, "_daily_ccld_fetch_v2", _boom("ccld")): + with pytest.raises(MustNotBeCalled): + await ens._fetch_todays_orders_strict_v2_impl(CODE) + + +@pytest.mark.asyncio +async def test_stale_day_unknown_reservation_is_pruned(entry_env): + ens._pending_entries[CODE] = 1.0 + ens._unknown_buy_entries[("2000-01-01", CODE)] = {"reserved": True, "status": "unresolved"} + async with day_trading_state.trade_lock: + ens._prune_unknown_buys_for_today() + assert CODE not in ens._pending_entries + assert ens._unknown_buy_entries == {} + + +# ═════════════════════════════════════════════════════════════════════════════ +# R1-2 주문 직전 신선가 +# ═════════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_fresh_price_rechecks_signal_and_resizes(entry_env, monkeypatch): + import app.services.trade_executor as te + fresh = CANDLE_PRICE * 1.004 + sent = {} + + async def _order(**kw): + sent.update(kw) + return {"rt_cd": "0", "output": {"ODNO": "0000900"}} + + monkeypatch.setattr(ens, "_fetch_fresh_price_v2_impl", AsyncMock(return_value=fresh)) + monkeypatch.setattr(te, "safe_place_order", _order) + res = await ens._try_enter(CODE, "T", TOTAL, CASH) + + assert res is not None + assert entry_env["signal_prices"] == [CANDLE_PRICE, fresh], "신선가로 같은 신호 함수를 재판정하지 않았다" + pos = day_trading_state.positions[CODE] + exp = _make_signal(fresh) + assert pos["entry_price"] == fresh + assert pos["stop_loss"] == exp.stop_loss and pos["target_price"] == exp.target_price + assert sent["quantity"] == min(_expected_qty(CANDLE_PRICE), _expected_qty(fresh)) + + +@pytest.mark.asyncio +async def test_fresh_price_recheck_failure_skips_order(entry_env, monkeypatch): + import app.services.trade_executor as te + calls = [] + + def _rsi_bb(code, name, candles_5m, price): + calls.append(price) + return _make_signal(price) if price == CANDLE_PRICE else None + + monkeypatch.setattr(ens, "check_rsi_bb_signal", _rsi_bb) + monkeypatch.setattr(ens, "_fetch_fresh_price_v2_impl", AsyncMock(return_value=CANDLE_PRICE * 0.98)) + monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order")) + assert await ens._try_enter(CODE, "T", TOTAL, CASH) is None + assert CODE not in ens._pending_entries + + +@pytest.mark.asyncio +async def test_fresh_price_lookup_failure_falls_back_to_candle_price(entry_env, monkeypatch): + import app.services.trade_executor as te + monkeypatch.setattr(ens, "_fetch_fresh_price_v2_impl", _real_fetch_fresh) + sent = {} + + async def _order(**kw): + sent.update(kw) + return {"rt_cd": "0", "output": {"ODNO": "1"}} + + monkeypatch.setattr(te, "safe_place_order", _order) + with patch.object(ens.korea_investment_api, "get_current_price", + AsyncMock(side_effect=RuntimeError("quote down"))): + assert await ens._try_enter(CODE, "T", TOTAL, CASH) is not None + assert entry_env["signal_prices"] == [CANDLE_PRICE] + assert day_trading_state.positions[CODE]["entry_price"] == CANDLE_PRICE + assert sent["quantity"] == _expected_qty(CANDLE_PRICE) + + +@pytest.mark.asyncio +async def test_fetch_fresh_price_parses_and_rejects_zero(): + with patch.object(ens.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "10050"}})): + assert await _real_fetch_fresh(CODE) == 10050.0 + with patch.object(ens.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "0"}})): + assert await _real_fetch_fresh(CODE) is None + + +def test_recheck_routes_by_strategy(monkeypatch): + seen = {} + monkeypatch.setattr(ens, "check_orb_signal", lambda c, n, cs, orb, p: seen.setdefault("orb", p) and "ORB") + monkeypatch.setattr(ens, "check_vwap_bounce_signal", lambda c, n, cs, v, p: seen.setdefault("vwap", p) and "V") + kw = dict(candles=[], candles_today=[], candles_5m=[], fresh_price=123.0) + assert ens._recheck_signal_with_fresh_price_v2( + SimpleNamespace(strategy="ORB"), CODE, "T", vwap=None, orb=object(), **kw) == "ORB" + assert ens._recheck_signal_with_fresh_price_v2( + SimpleNamespace(strategy="VWAP_BOUNCE"), CODE, "T", vwap=None, orb=None, **kw) is None + assert ens._recheck_signal_with_fresh_price_v2( + SimpleNamespace(strategy="VWAP_BOUNCE"), CODE, "T", vwap=1.0, orb=None, **kw) == "V" + assert seen == {"orb": 123.0, "vwap": 123.0} diff --git a/backend/tests/test_dt_exit_hardening_loop_r2_20261009.py b/backend/tests/test_dt_exit_hardening_loop_r2_20261009.py new file mode 100644 index 0000000..220420b --- /dev/null +++ b/backend/tests/test_dt_exit_hardening_loop_r2_20261009.py @@ -0,0 +1,245 @@ +"""DT 청산 하드닝 루프 라운드 2 회귀 테스트 (2026-10-09, daytrading/exit_service.py). + +HL2-1 매도 0체결 복원 ActiveTrade payload 에 원 진입 시각(created_at)·매수 ODNO(order_id)를 싣는다. +HL2-2 0체결 복원 포지션 재청산 시 같은 왕복을 loss_tracker·당일 연패에 두 번 세지 않고, + 부분체결 복원이면 체결 레그 순손익을 realized_net(왕복 누적)에 반영한다. + +목킹 원칙(CLAUDE.md §4.1): 주문 공통 종단(`safe_place_order`)은 BaseException 발생형으로 막고, +호출되면 안 되는 loss_tracker 기록도 예외 발생형으로 둔다. 기대값은 settings.* / 모듈 헬퍼에서 계산. +""" +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import pytz + +from app.core.config import settings +from app.services.daytrading import exit_service as es +from app.services.daytrading.position_manager import day_trading_state + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +CODE = "333333" +BUY_ODNO = "0000999" +_COLLS = ( + "_stale_sell_attempted", "_stale_sell_failures", "_last_exit_order_outcome", + "_unresolved_exit_unknown", "_stale_cleaned", "_exit_tokens", "_recent_dt_sell_finalized", + "_unknown_lookup_failures", +) + + +@pytest.fixture(autouse=True) +def _clean(): + saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today), + set(day_trading_state.dt_exited_today), day_trading_state.daily_pnl, + day_trading_state.loss_streak_today) + day_trading_state.reset_if_new_day() + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.dt_exited_today.clear() + day_trading_state.daily_pnl = 0.0 + day_trading_state.loss_streak_today = 0 + for name in _COLLS: + getattr(es, name).clear() + day_trading_state.exits_in_progress.clear() + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ + patch("app.services.trade_executor.safe_place_order", _boom("safe_place_order")), \ + patch.object(es, "_alert_once_v2", MagicMock()), \ + patch.object(es, "_in_closing_auction_impl", return_value=False), \ + patch.object(es, "enqueue_trade_history", AsyncMock()), \ + patch.object(es, "enqueue_close_active_trade", AsyncMock()), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \ + patch("app.services.portfolio_cache_bridge.reflect_sell", AsyncMock()): + yield + day_trading_state.positions.clear() + day_trading_state.positions.update(saved[0]) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved[1]) + day_trading_state.dt_exited_today.clear() + day_trading_state.dt_exited_today.update(saved[2]) + day_trading_state.daily_pnl = saved[3] + day_trading_state.loss_streak_today = saved[4] + for name in _COLLS: + getattr(es, name).clear() + day_trading_state.exits_in_progress.clear() + + +ENTRY = 10_000.0 +STOP = ENTRY * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT) + + +def _pos(qty=4, entry_time=None): + risk = ENTRY - STOP + return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": ENTRY, "quantity": qty, + "stop_loss": STOP, "target_price": ENTRY + risk * settings.DAY_TRADE_TARGET_MULTIPLIER, + "highest_price": ENTRY, + "entry_time": entry_time or (es._kst_now() - timedelta(minutes=12)).replace(microsecond=0), + "reason": "VWAP 반등", "order_id": BUY_ODNO} + + +async def _full_exit(pos, price, reason, *, rec, upserts, sell_odno="0000777"): + """전량 청산 접수 반영 → fill_reconciler 콜백을 캡처해 돌려준다.""" + captured = {} + + def _sched(code, order_id, side, qty, cb): + captured["cb"] = cb + return object() + + with patch.object(es.fill_reconciler, "schedule_v2", _sched), \ + patch("app.services.db_worker.enqueue_active_trade_upsert", upserts), \ + patch.object(es.loss_tracker, "record_trade_result_safe", rec), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()): + qty = int(pos["quantity"]) + res = await es._finalize_exit_impl( + CODE, pos, price, reason, 1.0, {"output": {"ODNO": sell_odno}}, qty, qty, True, + ) + assert res is not None + return captured["cb"] + + +async def _deliver(cb, info, upserts): + with patch("app.services.db_worker.enqueue_active_trade_upsert", upserts): + await cb(info) + + +# ═══ HL2-1 복원 ActiveTrade 에 원 진입 시각·ODNO ═══════════════════════════════ + +@pytest.mark.asyncio +async def test_zero_fill_restore_payload_carries_entry_time_and_order_id(): + pos = _pos(qty=4) + orig_entry_time = pos["entry_time"] + day_trading_state.positions[CODE] = pos + upserts = AsyncMock() + rec = AsyncMock() + cb = await _full_exit(dict(pos), STOP - 10, "손절 이탈", rec=rec, upserts=upserts) + await _deliver(cb, {"terminal": True, "filled_qty": 0, "avg_price": 0}, upserts) + + payload = upserts.await_args.args[0] + assert payload["status"] == "active" and payload["quantity"] == 4 + assert payload["created_at"] == orig_entry_time + assert payload["order_id"] == BUY_ODNO + assert day_trading_state.positions[CODE]["entry_time"] == orig_entry_time + + +@pytest.mark.asyncio +async def test_restore_payload_without_entry_time_omits_created_at(): + pos = _pos(qty=2) + pos["entry_time"] = None + pos["order_id"] = "" + upserts = AsyncMock() + with patch("app.services.db_worker.enqueue_active_trade_upsert", upserts): + await es._enqueue_restored_active_trade_impl(CODE, pos) + payload = upserts.await_args.args[0] + assert "created_at" not in payload # 모델 기본값(now_kst) — 지어내지 않는다 + assert "order_id" not in payload + + +def test_restored_created_at_normalizes_aware_and_rejects_future(): + kst = pytz.timezone("Asia/Seoul") + naive = (es._kst_now() - timedelta(minutes=5)).replace(microsecond=0) + aware_utc = kst.localize(naive).astimezone(pytz.utc) + assert es._restored_created_at_impl(aware_utc) == naive + assert es._restored_created_at_impl(naive) == naive + assert es._restored_created_at_impl(es._kst_now() + timedelta(hours=1)) is None + assert es._restored_created_at_impl("2026-10-09T09:10:00") is None + + +# ═══ HL2-2 재청산 이중 집계 방지 ═══════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_restored_position_reexit_does_not_double_count_loss(): + pos = _pos(qty=4) + day_trading_state.positions[CODE] = pos + upserts = AsyncMock() + rec1 = AsyncMock() + cb = await _full_exit(dict(pos), STOP - 10, "손절 이탈", rec=rec1, upserts=upserts) + assert rec1.await_count == 1 and rec1.await_args.args == (CODE, True) + assert day_trading_state.loss_streak_today == 1 + + await _deliver(cb, {"terminal": True, "filled_qty": 0, "avg_price": 0}, upserts) + cur = day_trading_state.positions[CODE] + assert cur[es._RESTORED_STREAK_MARK_KEY] is True + assert cur[es._RESTORED_LOSS_TRACKER_MARK_KEY] is True + assert es._FULL_EXIT_RECORD_KEY not in cur + pnl_after_restore = day_trading_state.daily_pnl + assert pnl_after_restore == pytest.approx(0.0) # 0체결 → 접수 시점 손익 되돌림 + + # 재청산(손실) — loss_tracker 기록은 호출되면 안 된다, 연패도 그대로 + await _full_exit(dict(cur), STOP - 20, "손절 이탈", rec=_boom("loss_tracker 재기록"), + upserts=upserts, sell_odno="0000778") + assert day_trading_state.loss_streak_today == 1 + leg = es.dt_net_pnl(ENTRY, STOP - 20, 4, cur)["net_profit_loss"] + assert day_trading_state.daily_pnl == pytest.approx(pnl_after_restore + leg) + assert CODE not in day_trading_state.positions + + +@pytest.mark.asyncio +async def test_restored_after_win_record_then_loss_is_recorded(): + """접수 시 이익(비손실)으로 기록 → 0체결 복원 → 재청산 손실이면 손실은 기록한다(보수적).""" + pos = _pos(qty=4) + day_trading_state.positions[CODE] = pos + upserts = AsyncMock() + rec1 = AsyncMock() + cb = await _full_exit(dict(pos), ENTRY * 1.03, "트레일링 스탑", rec=rec1, upserts=upserts) + assert rec1.await_args.args == (CODE, False) + await _deliver(cb, {"terminal": True, "filled_qty": 0, "avg_price": 0}, upserts) + cur = day_trading_state.positions[CODE] + assert cur[es._RESTORED_LOSS_TRACKER_MARK_KEY] is False + + rec2 = AsyncMock() + await _full_exit(dict(cur), STOP - 10, "손절 이탈", rec=rec2, upserts=upserts, sell_odno="0000778") + assert rec2.await_count == 1 and rec2.await_args.args == (CODE, True) + assert day_trading_state.loss_streak_today == 1 + + +@pytest.mark.asyncio +async def test_partial_fill_restore_adds_filled_leg_to_realized_net(): + pos = _pos(qty=4) + day_trading_state.positions[CODE] = pos + upserts = AsyncMock() + rec1 = AsyncMock() + fill_px = ENTRY * 1.02 + cb = await _full_exit(dict(pos), fill_px, "트레일링 스탑", rec=rec1, upserts=upserts) + await _deliver(cb, {"terminal": True, "filled_qty": 1, "avg_price": fill_px}, upserts) + + cur = day_trading_state.positions[CODE] + filled_leg = es.dt_net_pnl(ENTRY, fill_px, 1, pos)["net_profit_loss"] + assert cur["quantity"] == 3 + assert cur["realized_net"] == pytest.approx(filled_leg) + assert upserts.await_args.args[0]["quantity"] == 3 + + # 잔량 3주를 소폭 손실로 청산 — 왕복(체결 레그 +잔량)이 이익이면 이전 기록(이익)과 같아 재기록 없음 + small_loss_px = ENTRY * 0.999 + last_leg = es.dt_net_pnl(ENTRY, small_loss_px, 3, cur)["net_profit_loss"] + assert filled_leg + last_leg > 0, "테스트 전제: 왕복은 이익" + await _full_exit(dict(cur), small_loss_px, "EOD 청산", rec=_boom("loss_tracker 재기록"), + upserts=upserts, sell_odno="0000778") + assert day_trading_state.loss_streak_today == 0 + + +@pytest.mark.asyncio +async def test_plain_full_exit_without_marks_records_once(): + """표식 없는 일반 전량 청산은 기존대로 1회 기록(무변경 확인).""" + pos = _pos(qty=2) + day_trading_state.positions[CODE] = pos + rec = AsyncMock() + await _full_exit(dict(pos), STOP - 10, "손절 이탈", rec=rec, upserts=AsyncMock()) + assert rec.await_count == 1 and rec.await_args.args == (CODE, True) + assert day_trading_state.loss_streak_today == 1 + + +def test_should_record_outcome_rules(): + f = es._should_record_outcome_impl + assert f(None, True) is True and f(None, False) is True + assert f(True, True) is False and f(False, False) is False + assert f(False, True) is True # 이익 기록 후 손실 → 손실 기록 + assert f(True, False) is False # 손실 기록 유지(보수적) diff --git a/backend/tests/test_dt_exit_hardening_r1_20261009.py b/backend/tests/test_dt_exit_hardening_r1_20261009.py new file mode 100644 index 0000000..6ef6f3b --- /dev/null +++ b/backend/tests/test_dt_exit_hardening_r1_20261009.py @@ -0,0 +1,419 @@ +"""DT 청산 하드닝 라운드 1 회귀 테스트 (2026-10-09, daytrading/exit_service.py). + +R1-1 고아 근거를 DT 원장 순체결(BUY−SELL)>0 으로 한정 — 수동·HTS 합산분 매도 금지 +R1-2 고아 매도 결과 불명이어도 후보에서 빠지지 않는다(불명 재판정 경로 재도달) +R1-3/7 KB금융(105560) 재현 — 'synced' active 행 공존·DT 원장 순보유 0 → 팔지 않고 수동 경보 +R1-4 전일 포지션은 일반 청산 경로(손절 선판정·분봉·EOD·일일한도·공통 종단)에서 제외 +R1-5 매도 미체결 복원 시 ActiveTrade(day_trade) 도 되살린다 +R1-6 근거 없음 → error + 텔레그램(하루 1회) +R1-9 2단계 판정가 — 마지막 1분봉 이후 관측된 REST 현재가 재사용 + +목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`·`safe_place_order`)에 걸고, +호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 에서 계산한다. +""" +import asyncio +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.day_trading_strategy import MinuteCandle +from app.services.daytrading import exit_service as es +from app.services.daytrading import fill_reconciler as fr +from app.services.daytrading.position_manager import day_trading_state + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +class _SeqSession: + def __init__(self, results): + self._results = list(results) + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *a, **k): + rows = self._results.pop(0) if self._results else [] + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) + + async def commit(self): + raise MustNotBeCalled("commit") + + +def _sess(*results): + return lambda: _SeqSession(results) + + +def _balance(rows): + return AsyncMock(return_value={ + "rt_cd": "0", + "output1": [{"pdno": c, "hldg_qty": str(h), "ord_psbl_qty": str(s)} for c, h, s in rows], + "output2": [{}], + }) + + +KB = "105560" + + +def _pf(code=KB, qty=3): + return SimpleNamespace(stock_code=code, stock_name="KB금융", quantity=qty, + average_price=167_881.25, strategy_type="daytrading") + + +def _ev(code=KB, qty=3, days_ago=3): + return SimpleNamespace( + stock_code=code, stock_name="KB금융", quantity=qty, entry_price=166_866.0, + highest_since_entry=167_000.0, status="closed", strategy_name="day_trade", + created_at=datetime.now().replace(microsecond=0) - timedelta(days=days_ago), + ) + + +def _th_row(code, side, qty, status="COMPLETED", executed=None): + return SimpleNamespace(stock_code=code, order_type=side, status=status, + executed_quantity=qty if executed is None else executed, + order_quantity=qty) + + +@pytest.fixture(autouse=True) +def _clean(): + from app.services.telegram_notifier import telegram_notifier + saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today), + set(day_trading_state.dt_exited_today), day_trading_state.daily_pnl, + day_trading_state.last_total_assets) + day_trading_state.reset_if_new_day() + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.dt_exited_today.clear() + day_trading_state.daily_pnl = 0.0 + for coll in (es._stale_sell_attempted, es._stale_sell_failures, es._last_exit_order_outcome, + es._unresolved_exit_unknown, es._stale_cleaned, es._exit_tokens, + es._recent_dt_sell_finalized, es._manual_alerted, es._rest_price_seen): + coll.clear() + day_trading_state.exits_in_progress.clear() + tg = AsyncMock() + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ + patch.object(telegram_notifier, "notify_system_warning", tg): + yield tg + day_trading_state.positions.clear() + day_trading_state.positions.update(saved[0]) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved[1]) + day_trading_state.dt_exited_today.clear() + day_trading_state.dt_exited_today.update(saved[2]) + day_trading_state.daily_pnl = saved[3] + day_trading_state.last_total_assets = saved[4] + for coll in (es._unresolved_exit_unknown, es._manual_alerted, es._rest_price_seen, + es._recent_dt_sell_finalized): + coll.clear() + + +async def _run(session_factory, balance, exec_mock): + with patch.object(es, "is_market_open", return_value=True), \ + patch("app.core.database.AsyncSessionLocal", session_factory), \ + patch.object(es.korea_investment_api, "get_balance", balance), \ + patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "163400"}})), \ + patch.object(es, "_execute_exit_impl", exec_mock): + out = await es.close_stale_positions() + await asyncio.sleep(0) # 경보 태스크 실행 + await asyncio.sleep(0) + return out + + +# 쿼리 순서: ①DT active ③Portfolio DT ④DT 근거 행 ④b DT 원장 ⑤추가 비-DT + +# ═══ R1-1 ════════════════════════════════════════════════════════════════════ + +def test_net_qty_from_history_is_conservative(): + rows = [ + _th_row("A", "BUY", 3), _th_row("A", "SELL", 3), # 정상 왕복 → 0 + _th_row("B", "BUY", 3), _th_row("B", "SELL", 3, "CANCELLED", executed=0), # 0체결 → 3 + _th_row("C", "BUY", 3), _th_row("C", "SELL", 3, "PENDING", executed=None), # 불명 = 팔림 + _th_row("D", "BUY", 3, "PENDING"), # 미확인 매수 미산정 + _th_row("E", "BUY", 3), _th_row("E", "SELL", 3, "FAILED"), # 실패 매도 미산정 + ] + net = es._dt_net_qty_from_history(rows) + assert net.get("A", 0) == 0 + assert net["B"] == 3 + assert net.get("C", 0) == 0 + assert net.get("D", 0) == 0 + assert net["E"] == 3 + + +@pytest.mark.asyncio +async def test_manual_buy_after_normal_dt_exit_is_not_sold(_clean): + """DT 3주 정상 청산 뒤 수동 10주 — 'daytrading' 태그·closed DT 행이 남아도 팔지 않는다.""" + th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3)] + out = await _run(_sess([], [_pf(qty=10)], [_ev()], th), + _balance([(KB, 10, 10)]), _boom("_execute_exit_impl")) + assert out == [] + assert _clean.await_count == 1 # 수동 확인 경보 + + +@pytest.mark.asyncio +async def test_zero_fill_cancelled_sell_is_orphan_capped_by_net(_clean): + """근거 행 5주·Portfolio 10주라도 DT 원장 순보유(2주)까지만 판다.""" + th = [_th_row(KB, "BUY", 5), _th_row(KB, "SELL", 3), + _th_row(KB, "SELL", 2, "CANCELLED", executed=0)] + exec_mock = AsyncMock(return_value={"stock_code": KB}) + await _run(_sess([], [_pf(qty=10)], [_ev(qty=5)], th, []), + _balance([(KB, 10, 10)]), exec_mock) + assert exec_mock.await_count == 1 + assert exec_mock.await_args.args[1]["quantity"] == 2 + assert exec_mock.await_args.kwargs.get("is_stale") is True + + +# ═══ R1-2 ════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_orphan_with_unresolved_unknown_stays_candidate(): + """결과 불명이 남아 있어도 후보에 남아 stale 경로(불명 재판정)에 도달한다.""" + es._unresolved_exit_unknown[(es._today_key(), KB)] = { + "qty": 3, "since": es._kst_now(), "total_qty": 3, + } + th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)] + exec_mock = AsyncMock(return_value={"stock_code": KB}) + await _run(_sess([], [_pf()], [_ev()], th, []), _balance([(KB, 3, 3)]), exec_mock) + assert exec_mock.await_count == 1 + + +def test_restore_path_still_blocks_unresolved_unknown(): + es._unresolved_exit_unknown[(es._today_key(), KB)] = {"qty": 1, "since": es._kst_now()} + assert es._orphan_blocked_reason(KB, es._today_key()) == "결과 불명 매도 미해결" + assert es._orphan_blocked_reason_v2(KB, es._today_key(), block_unresolved_unknown=False) is None + + +# ═══ R1-3 / R1-7 (KB금융 운영 상태 재현) ═════════════════════════════════════ + +@pytest.mark.asyncio +async def test_kb_synced_row_and_net_zero_history_alerts_instead_of_selling(_clean): + """운영 DB(10/09): Portfolio 'daytrading' 3, DT 행 전부 closed, 'synced' active 3, + DT 원장 9/30·10/01·10/06 모두 3/3 왕복 → DT 몫 없음. 팔지 않고 수동 경보.""" + th = [_th_row(KB, s, 3) for s in ("BUY", "SELL", "BUY", "SELL", "BUY", "SELL")] + out = await _run(_sess([], [_pf()], [_ev()], th), _balance([(KB, 3, 3)]), + _boom("_execute_exit_impl")) + assert out == [] + assert _clean.await_count == 1 + assert "105560" in _clean.await_args.args[1] + + +@pytest.mark.asyncio +async def test_orphan_with_synced_row_and_no_dt_available_alerts_error(_clean): + """DT 원장상 3주 남았지만 실보유 3주 전부 'synced' 행 몫 → 팔지 않고 error+텔레그램.""" + synced = SimpleNamespace(stock_code=KB, quantity=3) + th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)] + await _run(_sess([], [_pf()], [_ev()], th, [synced]), _balance([(KB, 3, 3)]), + _boom("_execute_exit_impl")) + assert _clean.await_count == 1 + + +@pytest.mark.asyncio +async def test_orphan_with_synced_row_sells_only_dt_share(): + synced = SimpleNamespace(stock_code=KB, quantity=3) + th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)] + exec_mock = AsyncMock(return_value={"stock_code": KB}) + await _run(_sess([], [_pf(qty=6)], [_ev()], th, [synced]), _balance([(KB, 6, 6)]), exec_mock) + assert exec_mock.await_args.args[1]["quantity"] == 3 + + +# ═══ R1-6 ════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_no_evidence_alerts_once_per_day(_clean): + for _ in range(2): + await _run(_sess([], [_pf()], [], []), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl")) + assert _clean.await_count == 1 + + +# ═══ R1-4 전일 포지션은 일반 경로 제외 ═══════════════════════════════════════ + +def _stale_pos(qty=10, entry=10_000.0): + return {"stock_name": "전일", "entry_price": entry, "quantity": qty, + "stop_loss": entry * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT), + "target_price": entry * 1.05, "highest_price": entry, + "entry_time": datetime.now() - timedelta(days=1), "order_id": "1"} + + +@pytest.mark.asyncio +async def test_fast_stop_and_minute_check_skip_stale_position(): + day_trading_state.positions["111111"] = _stale_pos() + with patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \ + patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("chart")), \ + patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): + assert await es._fast_stop_check_impl("111111") == (True, None) + assert await es._check_single_position("111111") is None + + +@pytest.mark.asyncio +async def test_common_terminus_refuses_stale_position_in_general_path(): + pos = _stale_pos() + day_trading_state.positions["111111"] = pos + import app.services.trade_executor as te + with patch.object(te, "safe_place_order", _boom("safe_place_order")): + assert await es._execute_exit("111111", pos, 9_000.0, "손절") is None + assert "111111" not in day_trading_state.exits_in_progress + assert day_trading_state.daily_pnl == 0.0 + + +@pytest.mark.asyncio +async def test_force_close_all_routes_stale_to_close_stale_positions(): + day_trading_state.positions["111111"] = _stale_pos() + stale_mock = AsyncMock(return_value=[{"stock_code": "111111"}]) + + async def _exec(code, pos, price, reason, sell_pct, is_stale): + if not is_stale: + raise MustNotBeCalled("전일 포지션이 일반 경로로 매도됨") + return None + + with patch.object(es, "_execute_exit_impl", _exec), \ + patch.object(es, "close_stale_positions", stale_mock), \ + patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")): + out = await es.force_close_all(reason="EOD 강제 청산") + assert stale_mock.await_count == 1 + assert out == [{"stock_code": "111111"}] + + +@pytest.mark.asyncio +async def test_stale_overnight_gap_not_counted_in_daily_loss_limit(): + """전일 포지션 -20% 갭 미실현이 있어도 당일 -한도 강제청산이 발동하지 않는다.""" + pos = _stale_pos(qty=100, entry=10_000.0) + pos["current_price"] = 8_000.0 + day_trading_state.positions["111111"] = pos + day_trading_state.last_total_assets = 1_000_000.0 + with patch.object(es, "_ensure_total_assets_for_exit_impl", AsyncMock(return_value=1_000_000.0)), \ + patch.object(es, "force_close_all", _boom("force_close_all")), \ + patch.object(es, "is_eod_flat_time", return_value=False), \ + patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), \ + patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \ + patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("chart")), \ + patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): + assert await es.check_and_exit_positions() == [] + + +# ═══ R1-5 미체결 복원 → ActiveTrade 되살림 ═══════════════════════════════════ + +@pytest.fixture +def exit_env(): + import app.services.trade_executor as te + import app.services.portfolio_cache_bridge as pcb + cap = {"upserts": []} + + async def _ups(payload): + cap["upserts"].append(dict(payload)) + + with patch.object(te, "safe_place_order", + AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "0000123"}})), \ + patch.object(es, "enqueue_trade_history", AsyncMock()), \ + patch.object(es, "enqueue_close_active_trade", AsyncMock()), \ + patch("app.services.db_worker.enqueue_active_trade_upsert", _ups), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \ + patch.object(pcb, "reflect_sell", AsyncMock()), \ + patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0): + yield cap + + +def _today_pos(qty=10): + return {"stock_name": "테스트", "entry_price": 10_000.0, "quantity": qty, + "stop_loss": 9_700.0, "target_price": 10_450.0, "highest_price": 10_050.0, + "order_id": "0000999", "entry_time": datetime.now(), "reason": "ORB"} + + +@pytest.mark.asyncio +async def test_full_exit_zero_fill_reopens_active_trade(exit_env): + day_trading_state.positions["666666"] = _today_pos(10) + rows = [{"odno": "123", "pdno": "666666", "ord_qty": "10", "tot_ccld_qty": "0", + "avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}] + with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): + await es._execute_exit("666666", day_trading_state.positions["666666"], 9_900.0, "EOD 강제 청산") + await asyncio.gather(*list(fr._pending_tasks)) + assert day_trading_state.positions["666666"]["quantity"] == 10 + last = exit_env["upserts"][-1] + assert last["strategy_name"] == "day_trade" and last["status"] == "active" + assert last["quantity"] == 10 and last["entry_price"] == 10_000.0 + assert last["stop_loss"] == 9_700.0 and last["take_profit"] == 10_450.0 + + +@pytest.mark.asyncio +async def test_partial_exit_zero_fill_restores_active_trade_quantity(exit_env): + day_trading_state.positions["666666"] = _today_pos(10) + rows = [{"odno": "123", "pdno": "666666", "ord_qty": "5", "tot_ccld_qty": "0", + "avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}] + with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): + await es._execute_exit("666666", day_trading_state.positions["666666"], 10_500.0, + "목표가 도달", sell_pct=0.5) + await asyncio.gather(*list(fr._pending_tasks)) + assert day_trading_state.positions["666666"]["quantity"] == 10 + # finalize 가 잔량 5로 줄인 뒤, 미체결 복원이 10 으로 되돌린다 + assert [u["quantity"] for u in exit_env["upserts"]] == [5, 10] + last = exit_env["upserts"][-1] + # [R4-1 2026-10-09] 체결 0 = 분할이 없었던 것 → 목표가·partial_sold 를 분할 전으로 되돌리고 + # remaining_pct=1.0 으로 영속화한다(예전 기대값 inf/<1.0 은 분할 익절 영구 소실 결함이었다). + restored = day_trading_state.positions["666666"] + assert restored["target_price"] == pytest.approx(10_450.0) + assert restored["partial_sold"] is False + assert last["take_profit"] == pytest.approx(10_450.0) + assert last["remaining_pct"] == 1.0 + + +# ═══ R1-9 REST 현재가 재사용 ═════════════════════════════════════════════════ + +def _candle(dt: datetime, close: float) -> MinuteCandle: + return MinuteCandle(date=dt.strftime("%Y%m%d"), time=dt.strftime("%H%M%S"), + open=close, high=close, low=close, close=close, volume=100, + cum_volume=100, cum_amount=close * 100) + + +def test_fresher_rest_price_rules(): + now = es._kst_now() + candles = [_candle(now - timedelta(seconds=150), 100.0)] + es._rest_price_seen["A"] = (101.0, now) # 마지막 봉 +60초 이후 + assert es._fresher_rest_price_impl("A", candles) == 101.0 + fresh_candles = [_candle(now - timedelta(seconds=10), 100.0)] # 봉이 더 신선 + assert es._fresher_rest_price_impl("A", fresh_candles) is None + _old = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + float(settings.DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC) + 1 + es._rest_price_seen["A"] = (101.0, now - timedelta(seconds=_old)) # 같은 사이클 아님 + assert es._fresher_rest_price_impl("A", candles) is None + assert es._fresher_rest_price_impl("B", candles) is None + + +@pytest.mark.asyncio +async def test_minute_check_uses_fresher_rest_price_not_stale_candle_close(): + now = es._kst_now() + pos = _today_pos(10) + pos["entry_time"] = now - timedelta(minutes=5) + day_trading_state.positions["555555"] = pos + es._rest_price_seen["555555"] = (10_020.0, now) + candles = [_candle(now - timedelta(minutes=10) + timedelta(minutes=i), 10_000.0) for i in range(8)] + c5 = [SimpleNamespace(close=9_990.0, high=9_990.0, low=9_990.0)] * 6 + seen = {} + + def _exit(**kw): + seen.update(kw) + return None + + with patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(return_value={})), \ + patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", + AsyncMock(return_value=[])), \ + patch.object(es, "parse_minute_candles", return_value=candles), \ + patch.object(es, "aggregate_to_5min", return_value=c5), \ + patch.object(es, "check_day_trade_exit", _exit): + assert await es._check_single_position("555555") is None + assert seen["current_price"] == 10_020.0 + assert day_trading_state.positions["555555"]["current_price"] == 10_020.0 diff --git a/backend/tests/test_dt_exit_hardening_r2_20261009.py b/backend/tests/test_dt_exit_hardening_r2_20261009.py new file mode 100644 index 0000000..f53adad --- /dev/null +++ b/backend/tests/test_dt_exit_hardening_r2_20261009.py @@ -0,0 +1,295 @@ +"""DT 청산 하드닝 라운드 2 회귀 테스트 (2026-10-09, daytrading/exit_service.py). + +R2-1 15:20~15:30 종가 단일가 매도는 장 마감 후 재확인한 실제 체결가로 loss_tracker 승패 기록 +R2-3 국내 ETF/ETN 매도는 증권거래세 0 +R2-4 분할 익절 후 전량 청산의 승패는 왕복 순손익으로(당일 연패·loss_tracker) +R2-5 강제청산 None 반환도 실패로 재시도·텔레그램, 장 마감 후 잔존 DT 포지션 경보 + +목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`safe_place_order`·`_execute_exit_impl`)에 걸고, +호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 에서 계산한다. +""" +import asyncio +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.daytrading import exit_service as es +from app.services.daytrading import fill_reconciler as fr +from app.services.daytrading.position_manager import day_trading_state + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +@pytest.fixture(autouse=True) +def _clean(): + from app.services.telegram_notifier import telegram_notifier + saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today), + set(day_trading_state.dt_exited_today), day_trading_state.daily_pnl, + day_trading_state.loss_streak_today) + day_trading_state.reset_if_new_day() + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.dt_exited_today.clear() + day_trading_state.daily_pnl = 0.0 + day_trading_state.loss_streak_today = 0 + colls = (es._stale_sell_attempted, es._stale_sell_failures, es._last_exit_order_outcome, + es._unresolved_exit_unknown, es._stale_cleaned, es._exit_tokens, + es._recent_dt_sell_finalized, es._manual_alerted, es._once_alerted, + es._post_close_check_scheduled) + for coll in colls: + coll.clear() + day_trading_state.exits_in_progress.clear() + tg = AsyncMock() + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ + patch.object(telegram_notifier, "notify_system_warning", tg): + yield tg + day_trading_state.positions.clear() + day_trading_state.positions.update(saved[0]) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved[1]) + day_trading_state.dt_exited_today.clear() + day_trading_state.dt_exited_today.update(saved[2]) + day_trading_state.daily_pnl = saved[3] + day_trading_state.loss_streak_today = saved[4] + for coll in colls: + coll.clear() + day_trading_state.exits_in_progress.clear() + + +@pytest.fixture +def exit_env(): + """공통 종단 safe_place_order 접수 + 큐·캐시·loss_tracker 목.""" + import app.services.trade_executor as te + import app.services.portfolio_cache_bridge as pcb + rec = AsyncMock() + with patch.object(te, "safe_place_order", + AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "0000123"}})), \ + patch.object(es, "enqueue_trade_history", AsyncMock()), \ + patch.object(es, "enqueue_close_active_trade", AsyncMock()), \ + patch("app.services.db_worker.enqueue_active_trade_upsert", AsyncMock()), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \ + patch.object(pcb, "reflect_sell", AsyncMock()), \ + patch.object(es.loss_tracker, "record_trade_result_safe", rec), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()): + yield rec + + +def _pos(qty=10, entry=10_000.0, name="테스트"): + return {"stock_name": name, "entry_price": entry, "quantity": qty, + "stop_loss": entry * 0.97, "target_price": entry * 1.045, "highest_price": entry, + "order_id": "0000999", "entry_time": datetime.now(), "reason": "ORB"} + + +def _row(qty, filled, avg, rmn): + return [{"odno": "123", "pdno": "777777", "ord_qty": str(qty), "tot_ccld_qty": str(filled), + "avg_prvs": str(avg), "rmn_qty": str(rmn), "cncl_yn": "N"}] + + +# ═══ R2-3 ETF/ETN 거래세 0 ═══════════════════════════════════════════════════ + +@pytest.mark.parametrize("name", ["PLUS 200", "KIWOOM 200TR", "PLUS K방산", "KODEX 200", + "TIGER 미국S&P500", "삼성 인버스 2X WTI원유 선물 ETN"]) +def test_etf_etn_sell_tax_zero(name): + assert es._dt_sell_tax_rate({"stock_name": name}) == 0.0 + + +@pytest.mark.parametrize("name", ["삼성전자", "KB금융", "SK하이닉스", "", None]) +def test_stock_sell_tax_unchanged(name): + assert es._dt_sell_tax_rate({"stock_name": name}) == float(settings.KR_SELL_TAX_RATE_KOSPI) + assert es._dt_sell_tax_rate({"stock_name": name, "market": "KOSDAQ"}) == \ + float(settings.KR_SELL_TAX_RATE_KOSDAQ) + + +def test_etf_net_pnl_has_commission_only(): + """10/06 PLUS 200 재현: 비용은 수수료뿐(거래세 812원 없음).""" + r = es.dt_net_pnl(113_285.0, 112_840.0, 4, {"stock_name": "PLUS 200"}) + fee = float(settings.KR_COMMISSION_RATE) + expected_cost = (113_285.0 * 4 + 112_840.0 * 4) * fee + assert abs(r["total_cost"] - expected_cost) <= 2.0 + stock = es.dt_net_pnl(113_285.0, 112_840.0, 4, {"stock_name": "삼성전자"}) + assert stock["total_cost"] - r["total_cost"] >= 112_840.0 * 4 * float(settings.KR_SELL_TAX_RATE_KOSPI) - 2 + + +# ═══ R2-4 왕복 순손익 승패 ═══════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_partial_profit_then_small_loss_counts_as_round_trip_win(exit_env): + """+1.5R 분할 익절 뒤 잔량 소폭 손실 청산 → 왕복 이익 → 연패 리셋·loss_tracker 이익.""" + day_trading_state.positions["777777"] = _pos(10) + day_trading_state.loss_streak_today = 2 + with patch.object(es, "_in_closing_auction_impl", return_value=False): + r1 = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_450.0, + "목표가 도달", sell_pct=0.5) + assert r1["profit_loss"] > 0 + assert day_trading_state.positions["777777"]["realized_net"] == pytest.approx(r1["profit_loss"]) + assert exit_env.await_count == 0 # 분할 레그는 기록 안 함 + r2 = await es._execute_exit("777777", day_trading_state.positions["777777"], 9_990.0, + "트레일링 스탑") + assert r2["profit_loss"] < 0 # 마지막 레그만 보면 손실 + assert r1["profit_loss"] + r2["profit_loss"] > 0 # 왕복은 이익 + exit_env.assert_awaited_once_with("777777", False) + assert day_trading_state.loss_streak_today == 0 + assert day_trading_state.daily_pnl == pytest.approx(r1["profit_loss"] + r2["profit_loss"]) + + +@pytest.mark.asyncio +async def test_round_trip_loss_still_counts_as_loss(exit_env): + day_trading_state.positions["777777"] = _pos(10) + with patch.object(es, "_in_closing_auction_impl", return_value=False): + await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0, + "목표가 도달", sell_pct=0.5) + await es._execute_exit("777777", day_trading_state.positions["777777"], 9_700.0, "손절") + exit_env.assert_awaited_once_with("777777", True) + assert day_trading_state.loss_streak_today == 1 + + +@pytest.mark.asyncio +async def test_no_partial_keeps_last_leg_rule(exit_env): + day_trading_state.positions["777777"] = _pos(10) + with patch.object(es, "_in_closing_auction_impl", return_value=False): + r = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_010.0, "손절") + assert r["profit_loss"] < 0 # 비용 차감 후 손실 + exit_env.assert_awaited_once_with("777777", True) + assert day_trading_state.loss_streak_today == 1 + + +# ═══ R2-1 종가 단일가 매도 — 체결 확인 후 loss_tracker ═════════════════════════ + +@pytest.mark.asyncio +async def test_closing_auction_sell_records_loss_on_confirmed_fill(exit_env): + """15:20 신호가로는 이익이지만 15:30 체결가로 손실 → 체결 확인 후 '손실'로 1회 기록.""" + day_trading_state.positions["777777"] = _pos(10) + calls = {"n": 0} + + async def _orders(code): + calls["n"] += 1 + # 장 마감 전(즉시 확인) 구간에서는 loss_tracker 가 아직 기록되면 안 된다 + if calls["n"] <= int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS): + assert exit_env.await_count == 0 + return _row(10, 0, 0, 10) # 미체결·미종결 + return _row(10, 10, 9_950, 0) # 15:30 종가 체결 + + with patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0), \ + patch.object(es, "_in_closing_auction_impl", return_value=True), \ + patch.object(es, "_auction_recheck_params_impl", return_value=(0.0, 3, 0.0)), \ + patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \ + patch.object(fr.korea_investment_api, "get_todays_orders", _orders): + r = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0, + "EOD 강제 청산") + assert r["profit_loss"] > 0 # 신호가 기준은 이익 + await asyncio.gather(*list(es._auction_fill_tasks)) + exit_env.assert_awaited_once_with("777777", True) + expected = es.dt_net_pnl(10_000.0, 9_950.0, 10, _pos())["net_profit_loss"] + assert day_trading_state.daily_pnl == pytest.approx(expected) + + +@pytest.mark.asyncio +async def test_closing_auction_unconfirmed_falls_back_to_signal_price(exit_env): + """재확인까지 주문 행을 못 찾으면 신호가 기준으로 기록(구 동작) — 기록 누락 없음.""" + day_trading_state.positions["777777"] = _pos(10) + with patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0), \ + patch.object(es, "_in_closing_auction_impl", return_value=True), \ + patch.object(es, "_auction_recheck_params_impl", return_value=(0.0, 2, 0.0)), \ + patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \ + patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])): + await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0, + "EOD 강제 청산") + await asyncio.gather(*list(es._auction_fill_tasks)) + exit_env.assert_awaited_once_with("777777", False) + + +@pytest.mark.asyncio +async def test_closing_auction_zero_fill_does_not_record(exit_env): + """종가 단일가 매도가 0주 체결로 종결 → 포지션 복원, loss_tracker 기록하지 않음.""" + day_trading_state.positions["777777"] = _pos(10) + rows = [{"odno": "123", "pdno": "777777", "ord_qty": "10", "tot_ccld_qty": "0", + "avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}] + with patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0), \ + patch.object(es, "_in_closing_auction_impl", return_value=True), \ + patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \ + patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): + await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0, + "EOD 강제 청산") + await asyncio.gather(*list(es._auction_fill_tasks)) + assert exit_env.await_count == 0 + assert day_trading_state.positions["777777"]["quantity"] == 10 + + +@pytest.mark.asyncio +async def test_time_stop_in_auction_still_not_recorded(exit_env): + day_trading_state.positions["777777"] = _pos(10) + with patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", False), \ + patch.object(es, "_in_closing_auction_impl", return_value=True): + await es._execute_exit("777777", day_trading_state.positions["777777"], 9_960.0, + "시간 손절 (테스트)") + assert exit_env.await_count == 0 + + +def test_closing_auction_window_bounds(): + d = datetime(2026, 10, 6) + assert not es._in_closing_auction_impl(d.replace(hour=15, minute=19, second=59)) + assert es._in_closing_auction_impl(d.replace(hour=15, minute=20)) + assert es._in_closing_auction_impl(d.replace(hour=15, minute=29, second=59)) + assert not es._in_closing_auction_impl(d.replace(hour=15, minute=30)) + + +# ═══ R2-5 강제청산 미청산 경보 ═══════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_force_close_none_result_retried_and_alerted(_clean): + day_trading_state.positions["888888"] = _pos(5) + exec_mock = AsyncMock(return_value=None) # KIS 거부 등 — 예외 없이 None + with patch.object(es, "_execute_exit_impl", exec_mock), \ + patch.object(es, "is_eod_flat_time", return_value=False), \ + patch.object(es, "close_stale_positions", _boom("close_stale_positions")), \ + patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "10000"}})): + out = await es.force_close_all(reason="EOD 강제 청산") + await asyncio.gather(*list(es._alert_tasks)) + assert out == [] + assert exec_mock.await_count == 2 # 1회 + 재시도 1회 + tags = [c.args[0] for c in _clean.await_args_list] + assert tags == ["DT_EOD"] + assert "888888" in _clean.await_args_list[0].args[1] + + +@pytest.mark.asyncio +async def test_post_close_residual_check_alerts(_clean): + assert es.check_post_close_residual_v2() == [] + day_trading_state.positions["888888"] = _pos(5) + assert es.check_post_close_residual_v2() == ["888888"] + es.check_post_close_residual_v2() # 같은 날 같은 잔존 → 1회만 + await asyncio.gather(*list(es._alert_tasks)) + assert [c.args[0] for c in _clean.await_args_list] == ["DT_EOD"] + + +@pytest.mark.asyncio +async def test_eod_force_close_schedules_post_close_check_once(_clean): + day_trading_state.positions["888888"] = _pos(5) + day_trading_state.exits_in_progress.add("888888") # 불명 보류 중 → 이번 회차 skip + with patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")), \ + patch.object(es, "is_eod_flat_time", return_value=True), \ + patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \ + patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")): + await es.force_close_all(reason="EOD 강제 청산") + await es.force_close_all(reason="EOD 강제 청산") + assert len(es._post_close_tasks) <= 1 + await asyncio.gather(*list(es._post_close_tasks)) + await asyncio.gather(*list(es._alert_tasks)) + assert [c.args[0] for c in _clean.await_args_list] == ["DT_EOD"] diff --git a/backend/tests/test_dt_exit_hardening_r3_20261009.py b/backend/tests/test_dt_exit_hardening_r3_20261009.py new file mode 100644 index 0000000..38a204c --- /dev/null +++ b/backend/tests/test_dt_exit_hardening_r3_20261009.py @@ -0,0 +1,381 @@ +"""DT 청산 하드닝 라운드 3 회귀 테스트 (2026-10-09, daytrading/exit_service.py). + +R3-1 KIS 잔고 첫 페이지가 가득 찼는데 종목이 없으면 '0주'가 아니라 '판정 불가'(보류) +R3-2 장 마감 후 잔존 점검은 종가 매도 재확인이 끝난 뒤에, 장 마감 후 0체결 복원은 텔레그램 +R3-3 청산 판정 실패(상한 초과·예외·판정 불가)가 N 사이클 연속이면 텔레그램 + 헬스 노출 +R3-4 폴백 손절 대체가는 나이 상한 안의 KIS 관측가만 — 낡은 값/시각 없는 값은 '판정 불가' + +목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`)에, 호출되면 안 되는 경로는 +BaseException 발생형. 기대값은 settings.* / 모듈 헬퍼에서 계산한다. +""" +import asyncio +import time +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.daytrading import exit_service as es +from app.services.daytrading.position_manager import day_trading_state + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +def _fail(name): + """프로덕션이 처리해야 하는 일반 실패(KIS 장애 재현).""" + async def _f(*a, **k): + raise RuntimeError(f"KIS 장애 재현: {name}") + return _f + + +_COLLS = ( + "_stale_sell_attempted", "_stale_sell_failures", "_last_exit_order_outcome", + "_unresolved_exit_unknown", "_stale_cleaned", "_exit_tokens", "_recent_dt_sell_finalized", + "_manual_alerted", "_once_alerted", "_post_close_check_scheduled", "_price_seen_mono", + "_exit_check_failures", "_exit_stall_alerted", "_rest_price_seen", +) + + +@pytest.fixture(autouse=True) +def _clean(): + from app.services.telegram_notifier import telegram_notifier + saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today), + set(day_trading_state.dt_exited_today), day_trading_state.daily_pnl) + day_trading_state.reset_if_new_day() + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.dt_exited_today.clear() + day_trading_state.daily_pnl = 0.0 + for name in _COLLS: + getattr(es, name).clear() + day_trading_state.exits_in_progress.clear() + tg = AsyncMock() + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ + patch.object(telegram_notifier, "notify_system_warning", tg): + yield tg + day_trading_state.positions.clear() + day_trading_state.positions.update(saved[0]) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved[1]) + day_trading_state.dt_exited_today.clear() + day_trading_state.dt_exited_today.update(saved[2]) + day_trading_state.daily_pnl = saved[3] + for name in _COLLS: + getattr(es, name).clear() + day_trading_state.exits_in_progress.clear() + + +async def _drain_alerts(): + await asyncio.gather(*list(es._alert_tasks), return_exceptions=True) + + +def _tags(tg): + return [c.args[0] for c in tg.await_args_list] + + +def _pos(qty=4, entry=10_000.0, stop=9_700.0, entry_time=None): + return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": entry, "quantity": qty, + "stop_loss": stop, "target_price": entry * 1.045, "highest_price": entry, + "entry_time": entry_time or es._kst_now(), "reason": "", "order_id": "0000999"} + + +def _full_page(extra=None, n_other=None): + """첫 페이지가 가득 찬 잔고 응답 — 다른 종목 n 행(+ extra 행).""" + full = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) + rows = [{"pdno": f"9{i:05d}", "hldg_qty": "1", "ord_psbl_qty": "1"} + for i in range(full if n_other is None else n_other)] + if extra: + rows = rows[: max(0, len(rows) - len(extra))] + extra + return AsyncMock(return_value={"rt_cd": "0", "output1": rows, "output2": [{}]}) + + +class _SeqSession: + def __init__(self, results): + self._results = list(results) + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *a, **k): + rows = self._results.pop(0) if self._results else [] + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) + + +def _sess(*results): + return lambda: _SeqSession(results) + + +# ═══ R3-1 잔고 첫 페이지 가득 참 ═══════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_holdings_v3_flags_incomplete_and_v2_returns_none_on_full_page(): + with patch.object(es.korea_investment_api, "get_balance", _full_page()): + h, complete = await es._fetch_kis_holdings_v3_impl() + assert h is not None and complete is False + # v2(entry_service 결과 불명 매수 판정 경로)는 '미확인'으로 — hldg==0 미접수 오판 방지 + assert await es._fetch_kis_holdings_v2_impl() is None + + +@pytest.mark.asyncio +async def test_holdings_v2_unchanged_below_full_page(): + n = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) - 1 + with patch.object(es.korea_investment_api, "get_balance", _full_page(n_other=n)): + h, complete = await es._fetch_kis_holdings_v3_impl() + assert complete is True + h2 = await es._fetch_kis_holdings_v2_impl() + assert h2 is not None and len(h2) == n + assert (h2.get("064400") or {}).get("hldg", 0) == 0 # 페이지가 덜 찼으면 '없음 = 0주' 유지 + + +@pytest.mark.asyncio +async def test_stale_missing_on_full_page_is_not_closed(_clean): + """2페이지에 있을 수 있는 전일 DT 종목 → 매도 없이 ActiveTrade 정리 금지, 수동 경보.""" + day_trading_state.positions["064400"] = _p = _pos(entry_time=es._kst_now() - timedelta(days=1)) + with patch.object(es, "is_market_open", return_value=True), \ + patch("app.core.database.AsyncSessionLocal", _sess([], [])), \ + patch.object(es.korea_investment_api, "get_balance", _full_page()), \ + patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \ + patch.object(es, "enqueue_close_active_trade", _boom("enqueue_close_active_trade")), \ + patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): + assert await es.close_stale_positions() == [] + await _drain_alerts() + assert day_trading_state.positions.get("064400") is _p + assert "064400" not in day_trading_state.closed_today + assert not es._stale_cleaned + assert _tags(_clean) == ["DT_MANUAL_CHECK"] + + +@pytest.mark.asyncio +async def test_stale_found_on_full_page_still_sold(): + """페이지가 가득 차도 응답에 있는 종목은 수량을 믿고 정상 청산(§3.5 차감 유지).""" + day_trading_state.positions["064400"] = _pos(qty=4, entry_time=es._kst_now() - timedelta(days=1)) + exec_mock = AsyncMock(return_value={"stock_code": "064400"}) + bal = _full_page(extra=[{"pdno": "064400", "hldg_qty": "4", "ord_psbl_qty": "4"}]) + with patch.object(es, "is_market_open", return_value=True), \ + patch("app.core.database.AsyncSessionLocal", _sess([], [])), \ + patch.object(es.korea_investment_api, "get_balance", bal), \ + patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "10000"}})), \ + patch.object(es, "_execute_exit_impl", exec_mock): + await es.close_stale_positions() + assert exec_mock.await_count == 1 + assert exec_mock.await_args.args[1]["quantity"] == 4 + + +@pytest.mark.asyncio +async def test_unknown_exit_missing_on_full_page_is_lookup_failed(): + """결과 불명 매도: 미발견+페이지 가득 → 'accepted'(인메모리 제거) 금지, 판정 보류.""" + with patch.object(es.korea_investment_api, "get_balance", _full_page()), \ + patch.object(es, "_fetch_non_dt_active_qty_impl", _boom("_fetch_non_dt_active_qty_impl")), \ + patch.object(es, "_lookup_todays_orders_impl", _boom("_lookup_todays_orders_impl")): + status, resp, dt_sellable = await es._resolve_unknown_exit_v2_impl( + "064400", 4, es._kst_now(), 4, + ) + assert (status, resp, dt_sellable) == ("lookup_failed", None, 0) + + +@pytest.mark.asyncio +async def test_unknown_exit_missing_on_partial_page_still_accepted(): + """페이지가 덜 찼으면 기존 판정 유지(미발견 = 0주 → 접수).""" + n = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) - 1 + with patch.object(es.korea_investment_api, "get_balance", _full_page(n_other=n)), \ + patch.object(es, "_fetch_non_dt_active_qty_impl", AsyncMock(return_value=0)), \ + patch.object(es, "_lookup_todays_orders_impl", AsyncMock(return_value=[])): + status, _resp, _ = await es._resolve_unknown_exit_v2_impl("064400", 4, es._kst_now(), 4) + assert status == "accepted" + + +# ═══ R3-2 장 마감 후 잔존 점검 순서 + 0체결 복원 경보 ═══════════════════════════ + +@pytest.mark.asyncio +async def test_post_close_residual_waits_for_auction_recheck(_clean): + """재확인이 0체결로 포지션을 되살리기 전에 '잔존 없음'을 확정하지 않는다.""" + async def _recheck(): + await asyncio.sleep(0.05) # get_todays_orders 왕복 재현 + day_trading_state.positions["105560"] = _pos(qty=3) + + t = asyncio.get_running_loop().create_task(_recheck()) + es._auction_fill_tasks.add(t) + t.add_done_callback(es._auction_fill_tasks.discard) + with patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0): + es._schedule_post_close_residual_check_impl() + await asyncio.gather(*list(es._post_close_tasks)) + await _drain_alerts() + assert _tags(_clean) == ["DT_EOD"] + assert "105560" in _clean.await_args_list[0].args[1] + + +@pytest.mark.asyncio +async def test_post_close_wait_is_bounded(): + assert es._auction_recheck_wait_cap_impl() > 0 + never = asyncio.get_running_loop().create_task(asyncio.sleep(3600)) + es._auction_fill_tasks.add(never) + try: + with patch.object(es, "_auction_recheck_wait_cap_impl", return_value=0.05): + await asyncio.wait_for(es._await_auction_rechecks_impl(), timeout=2.0) + finally: + never.cancel() + es._auction_fill_tasks.discard(never) + + +@pytest.mark.asyncio +async def test_unfilled_restore_after_close_sends_telegram(_clean): + """15:30 이후 0체결 종결 → 인메모리 복원 + DT_EOD 텔레그램(오버나이트 확정).""" + after_close = datetime.combine(es._kst_now().date(), es._KRX_MARKET_CLOSE) + timedelta(minutes=1) + info = {"terminal": True, "filled_qty": 0, "avg_price": 0} + pos = _pos(qty=3) + with patch.object(es, "_kst_now", return_value=after_close), \ + patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \ + patch.object(es.fill_reconciler, "fetch_fill_v2", AsyncMock(return_value=info)), \ + patch.object(es, "_enqueue_restored_active_trade_impl", AsyncMock()), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True): + task = es._schedule_sell_fill_reconcile_v2( + stock_code="105560", pos=pos, order_id="0000411", signal_price=80_000.0, quantity=3, + entry_price=10_000.0, recorded_net=0.0, count_daily_pnl=True, closing_auction=True, + ) + assert task is not None + await task + await _drain_alerts() + assert day_trading_state.positions["105560"]["quantity"] == 3 + assert _tags(_clean) == ["DT_EOD"] + assert "105560" in _clean.await_args_list[0].args[1] + + +# ═══ R3-3 청산 판정 실패 연속 → 경보 ═══════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_bounded_v2_reports_timeout_and_exception(): + async def _slow(code): + await asyncio.sleep(1.0) + + async def _err(code): + raise RuntimeError("x") + + out, failed = await es._run_exit_checks_bounded_v2_impl(["A"], _slow, 0.05, "t") + assert out == {} and failed == {"A"} + out, failed = await es._run_exit_checks_bounded_v2_impl(["B"], _err, 1.0, "t") + assert failed == {"B"} + # 구 시그니처는 dict 만 반환(호환) + assert await es._run_exit_checks_bounded_impl(["C"], _err, 1.0, "t") == {} + + +def _cycle_patches(price_mock, chart_mock): + return ( + patch.object(es, "is_eod_flat_time", return_value=False), + patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), + patch.object(es, "_ensure_total_assets_for_exit_impl", AsyncMock(return_value=0.0)), + patch.object(es.korea_investment_api, "get_current_price", price_mock), + patch.object(es.korea_investment_api, "get_minute_chart_full_day", chart_mock), + patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")), + ) + + +@pytest.mark.asyncio +async def test_exit_check_stall_alerts_after_n_cycles_then_resets(_clean): + day_trading_state.positions["222222"] = _pos(qty=4) + n = es._exit_check_fail_alert_cycles_impl() + p = _cycle_patches(_fail("get_current_price"), _fail("get_minute_chart_full_day")) + with p[0], p[1], p[2], p[3], p[4], p[5]: + for i in range(n - 1): + assert await es.check_and_exit_positions() == [] + await _drain_alerts() + assert _tags(_clean) == [] # N-1 사이클까지는 경보 없음 + assert es.get_exit_check_health_v2()["failing"] == {"222222": n - 1} + await es.check_and_exit_positions() + await es.check_and_exit_positions() # 같은 연속 구간 → 1회만 + await _drain_alerts() + assert _tags(_clean) == ["DT_EXIT_STALL"] + assert es.get_exit_check_health_v2()["stalled"] == ["222222"] + + # 시세 회복(손절 미도달 판정 성공) → 카운터·경보 상태 해제 + ok_price = AsyncMock(return_value={"output": {"stck_prpr": "10000"}}) + p = _cycle_patches(ok_price, _fail("get_minute_chart_full_day")) + with p[0], p[1], p[2], p[3], p[4], p[5]: + await es.check_and_exit_positions() + assert es.get_exit_check_health_v2()["failing"] == {} + assert not es._exit_stall_alerted + + +@pytest.mark.asyncio +async def test_orb_window_price_failure_counts_as_check_failure(_clean): + day_trading_state.positions["333333"] = _pos(qty=4) + n = es._exit_check_fail_alert_cycles_impl() + with patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), \ + patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \ + patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): + for _ in range(n): + await es.check_stops_only_v2() + await _drain_alerts() + assert _tags(_clean) == ["DT_EXIT_STALL"] + + +# ═══ R3-4 폴백 손절 대체가 나이 ═══════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_fallback_ignores_untimed_position_price(): + """pos['current_price'] 만 있고 관측 시각이 없으면 '미도달'이 아니라 '판정 불가'.""" + pos = _pos(qty=4) + pos["current_price"] = 10_000.0 # 손절가(9,700)보다 높은 낡은 값 + day_trading_state.positions["444444"] = pos + with patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \ + patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): + out, failed = await es._run_exit_checks_bounded_v2_impl( + ["444444"], lambda c: es._check_fallback_stop_loss(c, pos, 9_700.0), 5.0, "t", + ) + assert out == {"444444": None} + assert failed == {"444444"} + + +@pytest.mark.asyncio +async def test_fallback_rejects_cached_price_older_than_max_age(): + pos = _pos(qty=4) + day_trading_state.positions["444444"] = pos + max_age = es._dt_fallback_price_max_age_impl() + es._price_seen_mono["444444"] = (10_000.0, time.monotonic() - max_age - 5.0) + with patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \ + patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): + _out, failed = await es._run_exit_checks_bounded_v2_impl( + ["444444"], lambda c: es._check_fallback_stop_loss(c, pos, 9_700.0), 5.0, "t", + ) + assert failed == {"444444"} + + +@pytest.mark.asyncio +async def test_fallback_uses_fresh_cached_price_and_stops(): + """나이 상한 안의 관측가는 쓴다 — 손절선 이탈이면 전량 청산(공통 종단 도달).""" + pos = _pos(qty=4) + day_trading_state.positions["444444"] = pos + es._remember_price_impl("444444", 9_600.0) + exec_mock = AsyncMock(return_value={"stock_code": "444444", "quantity": 4}) + with patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \ + patch.object(es, "_execute_exit_impl", exec_mock): + out, failed = await es._run_exit_checks_bounded_v2_impl( + ["444444"], lambda c: es._check_fallback_stop_loss(c, pos, 9_700.0), 5.0, "t", + ) + assert failed == set() + assert exec_mock.await_count == 1 + assert out["444444"]["quantity"] == 4 + + +@pytest.mark.asyncio +async def test_fast_stop_records_observed_price(): + day_trading_state.positions["555555"] = _pos(qty=4) + with patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "10100"}})), \ + patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): + assert await es._fast_stop_check_impl("555555") == (False, None) + assert es._price_seen_mono["555555"][0] == 10_100.0 diff --git a/backend/tests/test_dt_exit_hardening_r4_20261009.py b/backend/tests/test_dt_exit_hardening_r4_20261009.py new file mode 100644 index 0000000..d3acbc8 --- /dev/null +++ b/backend/tests/test_dt_exit_hardening_r4_20261009.py @@ -0,0 +1,194 @@ +"""DT 청산 하드닝 라운드 4 회귀 테스트 (2026-10-09, daytrading/exit_service.py). + +R4-1 1.5R 분할 매도가 체결 0 으로 종결되면 수량뿐 아니라 목표가·partial_sold 도 분할 전으로 + 되돌리고 ActiveTrade 에 remaining_pct=1.0 을 싣는다(부분체결이면 현행 유지). +R4-2 결과 불명 매도가 다음 판정에서 '접수'로 확정되면 **원 주문**의 사유·가격으로 반영한다 + (이번 사이클의 판정 사유로 확정해 loss_tracker 반영 여부가 뒤집히지 않게). + +목킹 원칙(CLAUDE.md §4.1): 주문 공통 종단(`safe_place_order`)은 BaseException 발생형으로 막는다. +기대값은 settings.* / 모듈 헬퍼에서 계산한다. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.core.config import settings +from app.services.day_trading_strategy import TIME_STOP_REASON_PREFIX +from app.services.daytrading import exit_service as es +from app.services.daytrading.position_manager import day_trading_state + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +CODE = "222222" +_COLLS = ( + "_stale_sell_attempted", "_stale_sell_failures", "_last_exit_order_outcome", + "_unresolved_exit_unknown", "_stale_cleaned", "_exit_tokens", "_recent_dt_sell_finalized", + "_unknown_lookup_failures", +) + + +@pytest.fixture(autouse=True) +def _clean(): + saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today), + set(day_trading_state.dt_exited_today), day_trading_state.daily_pnl) + day_trading_state.reset_if_new_day() + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.dt_exited_today.clear() + day_trading_state.daily_pnl = 0.0 + for name in _COLLS: + getattr(es, name).clear() + day_trading_state.exits_in_progress.clear() + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ + patch("app.services.trade_executor.safe_place_order", _boom("safe_place_order")), \ + patch.object(es, "_alert_once_v2", MagicMock()), \ + patch("app.services.portfolio_cache_bridge.reflect_sell", AsyncMock()): + yield + day_trading_state.positions.clear() + day_trading_state.positions.update(saved[0]) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved[1]) + day_trading_state.dt_exited_today.clear() + day_trading_state.dt_exited_today.update(saved[2]) + day_trading_state.daily_pnl = saved[3] + for name in _COLLS: + getattr(es, name).clear() + day_trading_state.exits_in_progress.clear() + + +def _pos(qty=4, entry=10_000.0, stop=9_700.0): + risk = entry - stop + return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": entry, "quantity": qty, + "stop_loss": stop, "target_price": entry + risk * settings.DAY_TRADE_TARGET_MULTIPLIER, + "highest_price": entry, "entry_time": es._kst_now(), "reason": "VWAP 반등", + "order_id": "0000999"} + + +async def _partial_leg_then_result(info): + """분할 50% 레그를 접수 반영(_finalize_exit_impl)한 뒤 체결 확인 결과(info)를 넘긴다.""" + pos = _pos(qty=4) + orig_target = pos["target_price"] + day_trading_state.positions[CODE] = pos + captured = {} + + def _sched(code, order_id, side, qty, cb): + captured["cb"] = cb + return object() + + upserts = AsyncMock() + with patch.object(es.fill_reconciler, "schedule_v2", _sched), \ + patch.object(es, "enqueue_trade_history", AsyncMock()), \ + patch("app.services.db_worker.enqueue_active_trade_upsert", upserts), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \ + patch.object(es.loss_tracker, "record_trade_result_safe", _boom("loss_tracker")): + res = await es._finalize_exit_impl( + CODE, pos, orig_target, "목표가 도달", 0.5, {"output": {"ODNO": "0000777"}}, + 2, 4, False, + ) + assert res is not None + cur = day_trading_state.positions[CODE] + assert cur["target_price"] == float("inf") and cur["partial_sold"] is True # 접수 시점 반영 + await captured["cb"](info) + return orig_target, upserts + + +@pytest.mark.asyncio +async def test_partial_leg_zero_fill_restores_target_and_partial_flag(): + orig_target, upserts = await _partial_leg_then_result( + {"terminal": True, "filled_qty": 0, "avg_price": 0}) + cur = day_trading_state.positions[CODE] + assert cur["quantity"] == 4 + assert cur["target_price"] == pytest.approx(orig_target) + assert cur["partial_sold"] is False + assert float(cur.get("realized_net", 0.0) or 0.0) == pytest.approx(0.0) + assert es._PRE_PARTIAL_STATE_KEY not in cur + payload = upserts.await_args.args[0] # 마지막 upsert = 복원 + assert payload["quantity"] == 4 + assert payload["remaining_pct"] == 1.0 + assert payload["take_profit"] == pytest.approx(orig_target) + + +@pytest.mark.asyncio +async def test_partial_leg_partial_fill_keeps_trailing_state(): + orig_target, upserts = await _partial_leg_then_result( + {"terminal": True, "filled_qty": 1, "avg_price": 10_450.0}) + cur = day_trading_state.positions[CODE] + assert cur["quantity"] == 3 + assert cur["target_price"] == float("inf") + assert cur["partial_sold"] is True + payload = upserts.await_args.args[0] + assert payload["remaining_pct"] < 1.0 + assert "take_profit" not in payload + + +# ═══ R4-2 결과 불명 매도 확정은 원 주문 사유·가격으로 ═══════════════════════════════ + +async def _confirm_unknown_with(orig_reason, orig_price, now_reason, now_price): + pos = _pos(qty=4) + day_trading_state.positions[CODE] = pos + es._unresolved_exit_unknown[(es._today_key(), CODE)] = { + "qty": 4, "since": es._kst_now(), "total_qty": 4, + "reason": orig_reason, "price": orig_price, "sell_pct": 1.0, + } + th = AsyncMock() + rec = AsyncMock() + resolved = {"rt_cd": "0", "output": {"ODNO": "0000555"}} + with patch.object(es, "_resolve_unknown_exit_v2_impl", + AsyncMock(return_value=("accepted", resolved, 0))), \ + patch.object(es, "_in_closing_auction_impl", return_value=False), \ + patch.object(es.fill_reconciler, "schedule_v2", MagicMock(return_value=object())), \ + patch.object(es, "enqueue_trade_history", th), \ + patch.object(es, "enqueue_close_active_trade", AsyncMock()), \ + patch.object(es.loss_tracker, "record_trade_result_safe", rec), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()): + res = await es._execute_exit(CODE, pos, now_price, now_reason) + return res, th.await_args.args[0], rec + + +@pytest.mark.asyncio +async def test_unknown_confirmed_uses_original_stop_reason_and_price(): + """원 주문=하드 손절, 이번 판정=시간손절 → 손절로 기록되고 loss_tracker 에 들어간다.""" + res, th_payload, rec = await _confirm_unknown_with( + "손절 9,700 이탈", 9_690.0, f"{TIME_STOP_REASON_PREFIX} (30분 경과)", 9_960.0) + assert res is not None and res["reason"] == "손절 9,700 이탈" + assert th_payload["exit_reason"] == "손절 9,700 이탈" + assert th_payload["order_price"] == pytest.approx(9_690.0) + assert rec.await_count == 1 and rec.await_args.args == (CODE, True) + assert CODE not in day_trading_state.positions + + +@pytest.mark.asyncio +async def test_unknown_confirmed_original_time_stop_stays_neutral(): + """원 주문=시간손절, 이번 판정=하드 손절 → 시간손절로 기록(loss_tracker 중립, §5.1).""" + orig = f"{TIME_STOP_REASON_PREFIX} (30분 경과)" + res, th_payload, rec = await _confirm_unknown_with(orig, 9_960.0, "손절 9,700 이탈", 9_690.0) + assert th_payload["exit_reason"] == orig + assert th_payload["order_price"] == pytest.approx(9_960.0) + assert rec.await_count == 0 + + +@pytest.mark.asyncio +async def test_unknown_registration_stores_original_order_fields(): + """불명 등록 시 원 사유·가격·매도비율을 함께 저장한다.""" + pos = _pos(qty=4) + day_trading_state.positions[CODE] = pos + unknown_resp = {"rt_cd": "", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "lost"} + with patch("app.services.trade_executor.safe_place_order", AsyncMock(return_value=unknown_resp)), \ + patch.object(es, "_resolve_unknown_exit_v2_impl", + AsyncMock(return_value=("not_accepted", None, 4))): + res = await es._execute_exit(CODE, pos, 9_690.0, "손절 9,700 이탈") + assert res is None + entry = es._unresolved_exit_unknown[(es._today_key(), CODE)] + assert entry["reason"] == "손절 9,700 이탈" + assert entry["price"] == pytest.approx(9_690.0) + assert entry["sell_pct"] == 1.0 + assert entry["qty"] == 4 diff --git a/backend/tests/test_dt_exit_partial_fill_pnl_20261009.py b/backend/tests/test_dt_exit_partial_fill_pnl_20261009.py new file mode 100644 index 0000000..e571ba2 --- /dev/null +++ b/backend/tests/test_dt_exit_partial_fill_pnl_20261009.py @@ -0,0 +1,128 @@ +"""DT 매도 미종결 부분체결 손익 계상 회귀 테스트 (2026-10-09, 하드닝 루프 R1 — exit_service.py). + +결함: fill 확인이 terminal=False 이고 0 < filled < quantity 일 때 `_on_fill` 이 +`dt_net_pnl(entry, avg, quantity)` 와 `total_amount = avg × quantity` 로 **미체결분까지 체결가로** +계상했다(장부·daily_pnl 오염). +수정: `_sell_fill_pnl_impl` — 체결분은 체결가, 미체결분은 신호가 추정, 거래대금·체결수량은 체결분만. + +목킹 원칙(CLAUDE.md §4.1): 주문 공통 종단(`safe_place_order`)과 체결 조회 종단 +(`fill_reconciler.korea_investment_api.get_todays_orders`)은 BaseException 발생형으로 막는다. +기대값은 dt_net_pnl(settings 기반 비용) 로 계산한다 — 비용률 하드코딩 없음. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.services.daytrading import exit_service as es +from app.services.daytrading import fill_reconciler +from app.services.daytrading.position_manager import day_trading_state + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +CODE = "333333" +ENTRY = 10_000.0 +SIGNAL = 9_800.0 +QTY = 4 + + +@pytest.fixture(autouse=True) +def _env(): + saved_pnl = day_trading_state.daily_pnl + saved_pos = dict(day_trading_state.positions) + day_trading_state.positions.clear() + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ + patch("app.services.trade_executor.safe_place_order", _boom("safe_place_order")), \ + patch.object(fill_reconciler.korea_investment_api, "get_todays_orders", + _boom("get_todays_orders")), \ + patch.object(es, "_alert_once_v2", MagicMock()): + yield + day_trading_state.daily_pnl = saved_pnl + day_trading_state.positions.clear() + day_trading_state.positions.update(saved_pos) + + +def _pos(): + return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": ENTRY, "quantity": QTY, + "stop_loss": 9_700.0, "highest_price": ENTRY, "reason": "VWAP 반등"} + + +async def _run_with(info, count_daily_pnl=True): + pos = _pos() + recorded = es.dt_net_pnl(ENTRY, SIGNAL, QTY, pos)["net_profit_loss"] + day_trading_state.daily_pnl = recorded if count_daily_pnl else 0.0 + captured = {} + + def _sched(code, order_id, side, qty, cb): + captured["cb"] = cb + return object() + + upd = AsyncMock() + with patch.object(es.fill_reconciler, "schedule_v2", _sched), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", upd): + es._schedule_sell_fill_reconcile_v2( + stock_code=CODE, pos=pos, order_id="0000555", signal_price=SIGNAL, quantity=QTY, + entry_price=ENTRY, recorded_net=recorded, count_daily_pnl=count_daily_pnl, + ) + await captured["cb"](info) + return pos, recorded, upd.await_args.args[1] + + +@pytest.mark.asyncio +async def test_non_terminal_partial_fill_books_only_filled_at_avg(): + avg, filled = 9_500.0, 1 + pos, recorded, payload = await _run_with({"terminal": False, "filled_qty": filled, "avg_price": avg}) + blended = (avg * filled + SIGNAL * (QTY - filled)) / QTY # 체결분 체결가 + 미체결분 신호가 + expect = es.dt_net_pnl(ENTRY, blended, QTY, pos)["net_profit_loss"] + wrong = es.dt_net_pnl(ENTRY, avg, QTY, pos)["net_profit_loss"] # 옛 계산(전량을 체결가로) + assert float(payload["net_profit_loss"]) == pytest.approx(round(expect, 2), abs=0.01) + assert float(payload["net_profit_loss"]) != pytest.approx(round(wrong, 2), abs=0.01) + assert float(payload["total_amount"]) == pytest.approx(avg * filled) + assert payload["executed_quantity"] == filled + assert day_trading_state.daily_pnl == pytest.approx(expect) + + +@pytest.mark.asyncio +async def test_full_fill_unchanged(): + avg = 9_750.0 + pos, recorded, payload = await _run_with({"terminal": True, "filled_qty": QTY, "avg_price": avg}) + expect = es.dt_net_pnl(ENTRY, avg, QTY, pos) + assert float(payload["net_profit_loss"]) == pytest.approx(round(expect["net_profit_loss"], 2), abs=0.01) + assert float(payload["total_amount"]) == pytest.approx(avg * QTY) + assert float(payload["commission"]) == pytest.approx(expect["total_cost"]) + assert "executed_quantity" not in payload + assert day_trading_state.daily_pnl == pytest.approx(expect["net_profit_loss"]) + + +@pytest.mark.asyncio +async def test_non_terminal_partial_stale_goes_to_stale_pnl_not_daily(): + before = es._stale_realized_pnl + try: + avg, filled = 9_500.0, 2 + pos, recorded, _ = await _run_with( + {"terminal": False, "filled_qty": filled, "avg_price": avg}, count_daily_pnl=False) + blended = (avg * filled + SIGNAL * (QTY - filled)) / QTY + expect = es.dt_net_pnl(ENTRY, blended, QTY, pos)["net_profit_loss"] + assert es._stale_realized_pnl - before == pytest.approx(expect - recorded) + assert day_trading_state.daily_pnl == 0.0 # 전일·고아 손익은 당일 미반영(§7) + finally: + es._stale_realized_pnl = before + + +def test_sell_fill_pnl_impl_linear_identity(): + """전량 체결과 '체결분+미체결분(같은 가격)' 합이 같아야 한다(dt_net_pnl 선형성).""" + pos = _pos() + full = es._sell_fill_pnl_impl(ENTRY, SIGNAL, SIGNAL, QTY, QTY, pos) + split = es._sell_fill_pnl_impl(ENTRY, SIGNAL, SIGNAL, 1, QTY, pos) + assert split["net_profit_loss"] == pytest.approx(full["net_profit_loss"]) + assert split["total_cost"] == pytest.approx(full["total_cost"]) + assert split["executed_quantity"] == 1 and split["is_partial"] is True + assert full["is_partial"] is False diff --git a/backend/tests/test_dt_exit_reject_alert_r4_20261009.py b/backend/tests/test_dt_exit_reject_alert_r4_20261009.py new file mode 100644 index 0000000..f0e6a6f --- /dev/null +++ b/backend/tests/test_dt_exit_reject_alert_r4_20261009.py @@ -0,0 +1,181 @@ +"""DT 청산 하드닝 라운드 4 — R4-3 청산 주문 연속 거부 경보 회귀 테스트 (2026-10-09). + +손절선에 닿은 DT 포지션의 시장가 매도가 KIS 업무 거부(rt_cd≠0)로 계속 실패해도 +logger.error 만 남고 청산 판정 헬스는 '성공'으로 리셋됐다(1단계 handled 종목은 2단계·헬스 실패 +집합에서 빠짐). 이제: + - 거부 1건마다 판정 실패 수집기에 기록 → 헬스 연속 실패로 센다(1단계 handled 포함) + - 종목별 거부 연속 횟수가 N(settings.DAY_TRADE_EXIT_CHECK_FAIL_ALERT_CYCLES) 이면 DT_EXIT_REJECT 텔레그램 1회 + - 접수(accepted) 1회면 리셋 + - 전일(stale) 경로는 _stale_sell_failures 가 따로 세므로 제외 + +목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`safe_place_order`)에 건다. 거부 응답은 시나리오 +자체(KIS 거부 재현)이고, 그 외 호출되면 안 되는 경로는 BaseException 발생형이다. +""" +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from app.services.daytrading import exit_service as es +from app.services.daytrading.position_manager import day_trading_state + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +_REJECT = {"rt_cd": "1", "msg_cd": "40910000", "msg1": "모의투자 주문처리가 안되었습니다"} + +_COLLS = ( + "_stale_sell_attempted", "_stale_sell_failures", "_last_exit_order_outcome", + "_last_exit_order_detail", "_unresolved_exit_unknown", "_exit_tokens", + "_once_alerted", "_price_seen_mono", "_exit_check_failures", "_exit_stall_alerted", + "_rest_price_seen", "_exit_reject_streak", "_exit_reject_alerted", "_alert_tasks", +) + + +@pytest.fixture(autouse=True) +def _clean(): + from app.services.telegram_notifier import telegram_notifier + saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today), + day_trading_state.daily_pnl) + day_trading_state.reset_if_new_day() + day_trading_state.positions.clear() + day_trading_state.daily_pnl = 0.0 + for name in _COLLS: + getattr(es, name).clear() + day_trading_state.exits_in_progress.clear() + tg = AsyncMock() + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ + patch.object(telegram_notifier, "notify_system_warning", tg): + yield tg + day_trading_state.positions.clear() + day_trading_state.positions.update(saved[0]) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved[1]) + day_trading_state.daily_pnl = saved[2] + for name in _COLLS: + getattr(es, name).clear() + day_trading_state.exits_in_progress.clear() + + +async def _drain_alerts(): + await asyncio.gather(*list(es._alert_tasks), return_exceptions=True) + + +def _tags(tg): + return [c.args[0] for c in tg.await_args_list] + + +def _pos(qty=4, entry=10_000.0, stop=9_700.0): + return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": entry, "quantity": qty, + "stop_loss": stop, "target_price": entry * 1.045, "highest_price": entry, + "entry_time": es._kst_now(), "reason": "", "order_id": "0000999"} + + +def _cycle_patches(order_mock): + """손절선 아래 현재가 → 1단계 손절 선판정이 매도 주문. 분봉 조회는 도달하면 안 된다.""" + return ( + patch.object(es, "is_eod_flat_time", return_value=False), + patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), + patch.object(es, "_ensure_total_assets_for_exit_impl", AsyncMock(return_value=0.0)), + patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "9500"}})), + patch.object(es.korea_investment_api, "get_minute_chart_full_day", + _boom("get_minute_chart_full_day")), + patch("app.services.trade_executor.safe_place_order", order_mock), + ) + + +@pytest.mark.asyncio +async def test_fast_stop_reject_counts_as_failure_and_alerts_after_n(_clean): + code = "444444" + day_trading_state.positions[code] = _pos() + n = es._exit_check_fail_alert_cycles_impl() + order = AsyncMock(return_value=dict(_REJECT)) + p = _cycle_patches(order) + with p[0], p[1], p[2], p[3], p[4], p[5]: + for _ in range(n - 1): + assert await es.check_and_exit_positions() == [] + await _drain_alerts() + assert "DT_EXIT_REJECT" not in _tags(_clean) # N-1 회까지는 경보 없음 + # 1단계 handled 종목이라도 거부면 헬스가 '성공'으로 리셋되지 않는다 + h = es.get_exit_check_health_v2() + assert h["failing"] == {code: n - 1} + assert h["order_rejects"] == {code: n - 1} + await es.check_and_exit_positions() + await es.check_and_exit_positions() # 같은 연속 구간 → 1회만 + await _drain_alerts() + tags = _tags(_clean) + assert tags.count("DT_EXIT_REJECT") == 1 + msg = next(c.args[1] for c in _clean.await_args_list if c.args[0] == "DT_EXIT_REJECT") + assert "40910000" in msg and f"수량={_pos()['quantity']}주" in msg + assert order.await_count == n + 1 # 매매 로직(매 사이클 재주문)은 그대로 + assert code in day_trading_state.positions # 거부라 포지션 유지 + assert es.get_exit_check_health_v2()["stalled"] == [code] + + +@pytest.mark.asyncio +async def test_accept_resets_reject_streak(_clean): + code = "555555" + day_trading_state.positions[code] = _pos() + p = _cycle_patches(AsyncMock(return_value=dict(_REJECT))) + with p[0], p[1], p[2], p[3], p[4], p[5]: + await es.check_and_exit_positions() + await es.check_and_exit_positions() + assert es._exit_reject_streak[code] == 2 + + accepted = {"rt_cd": "0", "msg_cd": "", "msg1": "ok", "output": {"ODNO": "1"}} + fin = AsyncMock(return_value=None) # 접수 이후 반영은 이 테스트 범위 밖 + p = _cycle_patches(AsyncMock(return_value=accepted)) + with p[0], p[1], p[2], p[3], p[4], p[5], patch.object(es, "_finalize_after_accept_impl", fin): + await es.check_and_exit_positions() + assert fin.await_count == 1 + assert code not in es._exit_reject_streak + assert code not in es._exit_reject_alerted + assert es.get_exit_check_health_v2()["failing"] == {} + + +@pytest.mark.asyncio +async def test_stage2_reject_marks_bounded_failure_through_shield(_clean): + """2단계(시간손절·트레일링 등)의 거부도 판정 실패로 수집된다(shield 태스크 경유 컨텍스트 전파).""" + code = "666666" + pos = _pos() + day_trading_state.positions[code] = pos + + async def _fn(c): + return await es._execute_exit(c, pos, 9_950.0, "시간손절 테스트", sell_pct=1.0) + + with patch("app.services.trade_executor.safe_place_order", AsyncMock(return_value=dict(_REJECT))): + out, failed = await es._run_exit_checks_bounded_v2_impl([code], _fn, 5.0, "청산 판정") + assert out == {code: None} + assert failed == {code} + assert es._exit_reject_streak[code] == 1 + + +@pytest.mark.asyncio +async def test_stale_path_reject_not_counted(_clean): + """전일 경로 거부는 _stale_sell_failures 담당 — R4-3 카운터·경보에 넣지 않는다.""" + code = "777777" + pos = _pos() + with patch("app.services.trade_executor.safe_place_order", AsyncMock(return_value=dict(_REJECT))): + assert await es._execute_exit_stale(code, pos, 9_500.0, "전일 미청산") is None + assert es._last_exit_order_outcome[code] == "rejected" + assert code not in es._exit_reject_streak + + +@pytest.mark.asyncio +async def test_reject_counter_cleared_when_position_gone(_clean): + code = "888888" + day_trading_state.positions[code] = _pos() + es._note_exit_order_rejected_impl(code, 4, "40910000", "x", "손절") + assert es._exit_reject_streak[code] == 1 + day_trading_state.positions.pop(code) + es._update_exit_check_health_impl([code], set(), "청산 판정") + assert code not in es._exit_reject_streak diff --git a/backend/tests/test_dt_exit_rsi_warmup_r3_20261009.py b/backend/tests/test_dt_exit_rsi_warmup_r3_20261009.py new file mode 100644 index 0000000..d65990e --- /dev/null +++ b/backend/tests/test_dt_exit_rsi_warmup_r3_20261009.py @@ -0,0 +1,200 @@ +"""DT 청산 RSI 워밍업 회귀 테스트 (하드닝 라운드 3, 2026-10-09, daytrading/exit_service.py). + +결함: 진입은 전일 봉을 붙인 워밍업 시계열(get_minute_chart_full_day_v2(include_prev_day=True)) +로 RSI(14)를 계산하는데, 청산(_check_single_position)은 당일 봉만 써서 당일 5분봉 15개 +미만(약 10:10 이전)에는 RSI=None → '과매수 청산'이 구조적으로 발동하지 않았다. + +수정: RSI 판정 시계열만 진입과 같은 구성(전일 워밍업 + 당일 봉)으로 맞춘다. 현재가·고점은 +당일 봉, ATR 은 `_atr` 이 당일 세션만 쓴다. 임계값(DAY_TRADE_RSI_EXIT_OVERBOUGHT)은 불변. + +목킹(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`·`safe_place_order`)에 예외 발생형. +""" +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services import day_trading_strategy as dts +from app.services.daytrading import entry_service as ens +from app.services.daytrading import exit_service as es +from app.services.daytrading.position_manager import day_trading_state + +TODAY = "20261008" +PREV = "20261007" +NOW = datetime(2026, 10, 8, 9, 40, 30) # KST naive — ORB 진입 직후 구간 +ENTRY = datetime(2026, 10, 8, 9, 31, 0) +CODE = "123450" + + +class ExitReached(BaseException): + """공통 종단 도달 — 청산 신호가 났다는 증거. except Exception 에 삼켜지지 않는다.""" + + +class LiveCallBlocked(BaseException): + """호출되면 안 되는 경로.""" + + +def _boom(name): + async def _f(*a, **k): + raise LiveCallBlocked(f"호출 금지 경로 도달: {name}") + return _f + + +def _row(date, hhmm, c, v=1000): + return { + "stck_bsop_date": date, "stck_cntg_hour": f"{hhmm}00", + "stck_oprc": str(c - 5), "stck_hgpr": str(c + 5), "stck_lwpr": str(c - 10), + "stck_prpr": str(c), "cntg_vol": str(v), "acml_vol": "0", "acml_tr_pbmn": "0", + } + + +def _today_rows(): + """09:00~09:39 1분봉(5분봉 8개). 마지막은 상승 추세로 RSI 과열.""" + rows, t, px = [], datetime(2026, 10, 8, 9, 0), 10_000 + while t < datetime(2026, 10, 8, 9, 40): + # 약간의 눌림을 섞어 RSI 가 100 고정이 되지 않게 + px += -10 if t.minute % 7 == 3 else 15 + rows.append(_row(TODAY, t.strftime("%H%M"), px)) + t += timedelta(minutes=1) + return rows + + +def _prev_rows(): + """전일 14:00~15:19 1분봉(5분봉 16개) — 워밍업.""" + rows, t, px = [], datetime(2026, 10, 7, 14, 0), 9_800 + while t < datetime(2026, 10, 7, 15, 20): + px += -8 if t.minute % 4 == 0 else 6 + rows.append(_row(PREV, t.strftime("%H%M"), px)) + t += timedelta(minutes=1) + return rows + + +def _pos(): + rows = _today_rows() + last = float(rows[-1]["stck_prpr"]) + return { + "stock_name": "테스트", "strategy": "ORB", "entry_price": 10_000.0, + # 손절·목표는 신호에 개입하지 않게 멀리, 고점=현재가(트레일링 미발동) + "stop_loss": 9_000.0, "target_price": 20_000.0, "quantity": 10, + "highest_price": last, "entry_time": ENTRY, + } + + +@pytest.fixture(autouse=True) +def _isolated(): + import app.services.trade_executor as te + + saved = dict(day_trading_state.positions) + day_trading_state.positions.clear() + es._rsi_warmup_prev_cache.clear() + es._rest_price_seen.pop(CODE, None) + ens._candle_cache.pop(CODE, None) + + async def _reach(stock_code, pos, price, reason, sell_pct, is_stale): + raise ExitReached(reason) + + with patch.object(es, "_kst_now", lambda: NOW), \ + patch.object(te, "safe_place_order", _boom("safe_place_order")), \ + patch.object(es, "_execute_exit_impl", _reach): + yield + day_trading_state.positions.clear() + day_trading_state.positions.update(saved) + es._rsi_warmup_prev_cache.clear() + ens._candle_cache.pop(CODE, None) + + +def _today_only_rsi_is_none(): + c5 = dts.aggregate_to_5min(dts.parse_minute_candles(_today_rows())) + return len(c5) < 15 and dts._rsi(c5, 14) is None + + +@pytest.mark.asyncio +async def test_0940_orb_position_overbought_exit_with_prev_day_warmup(): + """09:40 ORB 포지션, 당일 5분봉 8개 + 전일 워밍업 → RSI≥임계면 과매수 청산 신호.""" + assert _today_only_rsi_is_none() # 전제: 당일 봉만으로는 RSI 판정 불가 + warm = dts.aggregate_to_5min(dts.parse_minute_candles(_prev_rows() + _today_rows())) + rsi = dts._rsi(warm, 14) + assert rsi is not None and rsi >= float(settings.DAY_TRADE_RSI_EXIT_OVERBOUGHT) + + day_trading_state.positions[CODE] = _pos() + with patch.object(es.korea_investment_api, "get_minute_chart_full_day", + AsyncMock(return_value=_today_rows())), \ + patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", + AsyncMock(return_value=_prev_rows() + _today_rows())): + with pytest.raises(ExitReached) as ei: + await es._check_single_position(CODE) + assert "과매수 청산" in str(ei.value) + assert f"{float(settings.DAY_TRADE_RSI_EXIT_OVERBOUGHT):g}" in str(ei.value) + + +@pytest.mark.asyncio +async def test_without_warmup_no_overbought_signal_and_no_crash(): + """워밍업 확보 실패(빈 결과) → 종전 동작(당일 봉만, RSI None) — 주문 경로 미도달.""" + day_trading_state.positions[CODE] = _pos() + with patch.object(es.korea_investment_api, "get_minute_chart_full_day", + AsyncMock(return_value=_today_rows())), \ + patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", + AsyncMock(return_value=[])): + assert await es._check_single_position(CODE) is None + + +@pytest.mark.asyncio +async def test_warmup_fetch_error_is_contained_and_retry_throttled(): + """워밍업 조회 예외는 판정을 막지 않고, 재시도 간격 안에서는 다시 조회하지 않는다.""" + day_trading_state.positions[CODE] = _pos() + v2 = AsyncMock(side_effect=RuntimeError("kis down")) + with patch.object(es.korea_investment_api, "get_minute_chart_full_day", + AsyncMock(return_value=_today_rows())), \ + patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", v2): + assert await es._check_single_position(CODE) is None + assert await es._check_single_position(CODE) is None + assert v2.await_count == 1 + + +@pytest.mark.asyncio +async def test_prev_day_warmup_cached_once_per_day(): + day_trading_state.positions[CODE] = _pos() + v2 = AsyncMock(return_value=_prev_rows() + _today_rows()) + with patch.object(es.korea_investment_api, "get_minute_chart_full_day", + AsyncMock(return_value=_today_rows())), \ + patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", v2): + for _ in range(2): + with pytest.raises(ExitReached): + await es._check_single_position(CODE) + assert v2.await_count == 1 + + +@pytest.mark.asyncio +async def test_entry_scan_cache_reused_without_network(): + """진입 스캔 캐시(같은 워밍업 시계열)에 전일 봉이 있으면 네트워크 조회를 하지 않는다.""" + import time as _t + ens._candle_cache[CODE] = ( + _t.monotonic(), dts.parse_minute_candles(_prev_rows() + _today_rows()) + ) + day_trading_state.positions[CODE] = _pos() + with patch.object(es.korea_investment_api, "get_minute_chart_full_day", + AsyncMock(return_value=_today_rows())), \ + patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", + _boom("get_minute_chart_full_day_v2")): + with pytest.raises(ExitReached) as ei: + await es._check_single_position(CODE) + assert "과매수 청산" in str(ei.value) + + +def test_signal_series_keeps_today_atr_and_current_bar(): + """워밍업을 붙여도 ATR(당일 세션)·마지막 봉(현재가 기준)은 당일 봉만 쓴 것과 같다.""" + today = dts.parse_minute_candles(_today_rows()) + prev = dts.parse_minute_candles(_prev_rows()) + only = dts.aggregate_to_5min(today) + merged = es._exit_signal_candles_5m_impl(today, prev) + assert len(merged) > len(only) + assert dts._atr(merged, 14) == dts._atr(only, 14) + assert merged[-1] == only[-1] + + +def test_prev_session_filter_excludes_today_and_blank_date(): + rows = dts.parse_minute_candles(_prev_rows()[:2] + _today_rows()[:2]) + rows[0].date = "" + out = es._prev_session_candles_impl(rows, TODAY) + assert [c.date for c in out] == [PREV] diff --git a/backend/tests/test_dt_fill_late_recheck_r1_20261009.py b/backend/tests/test_dt_fill_late_recheck_r1_20261009.py new file mode 100644 index 0000000..cd0820d --- /dev/null +++ b/backend/tests/test_dt_fill_late_recheck_r1_20261009.py @@ -0,0 +1,207 @@ +"""[하드닝 R1 2026-10-09] DT 체결가 확인 누락 — 지연 재확인 + 시간초과 시 본 행 보존. + +운영 실측(trade_history daytrading 10/06~10/08): SELL 8/18·BUY 3/19 slippage NULL(체결 미확인). +즉시 확인 창(≈6초)에서 종결 행을 못 보면 신호가 기준 손익이 그대로 남아 일일 -3% 한도·연패 판정이 +실제와 어긋난다. fill_reconciler 가 종결을 못 본 경우 간격을 넓혀 더 조회하고, 결과는 1회만 넘긴다. + +목킹: KIS 조회(`get_todays_orders`)는 회차별 응답 목록을 소진하면 **예외 발생형**(BaseException)으로 +막는다 — 예상보다 많이 부르면 거짓 통과 대신 실패한다. 설정은 settings 를 참조/패치한다. +""" +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.daytrading import fill_reconciler as fr + + +class MustNotBeCalled(BaseException): + """예상 밖 호출 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _scripted(responses): + """회차별 응답. 원소가 BaseException/Exception 인스턴스면 raise, 소진되면 MustNotBeCalled.""" + calls = {"n": 0} + + async def _f(*a, **k): + i = calls["n"] + calls["n"] += 1 + if i >= len(responses): + raise MustNotBeCalled(f"get_todays_orders 예상 밖 {i + 1}번째 호출") + r = responses[i] + if isinstance(r, BaseException): + raise r + return r + + return _f, calls + + +@pytest.fixture +def fill_on(monkeypatch): + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True) + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0) + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0) + + +def _n_initial(): + return max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)) + + +def _row(odno, filled, avg, ord_qty, rmn): + return {"odno": odno, "tot_ccld_qty": str(filled), "avg_prvs": str(avg), + "ord_qty": str(ord_qty), "rmn_qty": str(rmn)} + + +def test_late_params_default_scale_and_override(monkeypatch): + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 2.0) + attempts, interval, per_call = fr._late_recheck_params_impl() + assert attempts == getattr(settings, "DAY_TRADE_FILL_LATE_RECHECK_ATTEMPTS", + fr._LATE_RECHECK_DEFAULT_ATTEMPTS) + if getattr(settings, "DAY_TRADE_FILL_LATE_RECHECK_INTERVAL_SEC", None) is None: + assert interval == pytest.approx(2.0 * fr._LATE_RECHECK_INTERVAL_FACTOR) + assert per_call == pytest.approx(float(settings.DAY_TRADE_FILL_CHECK_TIMEOUT_SEC)) + + + class _Override: + """config 키가 추가됐을 때의 경로(pydantic Settings 에는 미정의 필드를 넣을 수 없어 대리 객체).""" + DAY_TRADE_FILL_LATE_RECHECK_ATTEMPTS = -3 + DAY_TRADE_FILL_LATE_RECHECK_INTERVAL_SEC = 7.5 + + def __getattr__(self, name): + return getattr(settings, name) + + monkeypatch.setattr(fr, "settings", _Override()) + a2, i2, _ = fr._late_recheck_params_impl() + assert a2 == 0 and i2 == 7.5 + + +@pytest.mark.asyncio +async def test_row_missing_initially_then_confirmed_late(fill_on): + """즉시 확인에서 행이 안 보이다가 지연 재확인에서 전량 체결 → on_result 1회(체결가 반영).""" + row = _row("0000000123", 10, 10_080, 10, 0) + f, calls = _scripted([[]] * _n_initial() + [[], [row]]) + got = [] + + async def _on(info): + got.append(info) + + with patch.object(fr.korea_investment_api, "get_todays_orders", f): + t = fr.schedule_v2("005930", "123", "SELL", 10, _on) + await t + assert len(got) == 1 + assert got[0]["terminal"] is True and got[0]["filled_qty"] == 10 + assert got[0]["avg_price"] == pytest.approx(10_080.0) + assert calls["n"] == _n_initial() + 2 # 종결 확인 즉시 중단 + + +@pytest.mark.asyncio +async def test_non_terminal_zero_fill_then_late_fill_delivered_once(fill_on): + """즉시 확인은 접수만(체결 0, 미종결) → 지연 재확인에서 체결 종결. 콜백은 최종 1회만.""" + pending = _row("77", 0, 0, 5, 5) + done = _row("77", 5, 50_100, 5, 0) + f, calls = _scripted([[pending]] * _n_initial() + [[pending], [done]]) + got = [] + + async def _on(info): + got.append(info) + + with patch.object(fr.korea_investment_api, "get_todays_orders", f): + await fr.schedule_v2("000660", "77", "BUY", 5, _on) + assert [g["filled_qty"] for g in got] == [5] + assert got[0]["terminal"] is True + + +@pytest.mark.asyncio +async def test_terminal_on_first_check_no_late_calls(fill_on): + row = _row("9", 3, 1_000, 3, 0) + f, calls = _scripted([[row]]) # 두 번째 호출이 있으면 MustNotBeCalled + got = [] + + async def _on(info): + got.append(info) + + with patch.object(fr.korea_investment_api, "get_todays_orders", f): + await fr.schedule_v2("111111", "9", "SELL", 3, _on) + assert calls["n"] == 1 and len(got) == 1 + + +@pytest.mark.asyncio +async def test_never_found_no_callback_and_final_warning(fill_on, caplog): + late_n = fr._late_recheck_params_impl()[0] + f, calls = _scripted([[]] * (_n_initial() + late_n)) + + async def _on(info): + raise MustNotBeCalled("미확인인데 on_result 호출") + + with patch.object(fr.korea_investment_api, "get_todays_orders", f): + await fr.schedule_v2("222222", "5", "SELL", 4, _on) + assert calls["n"] == _n_initial() + late_n + assert any("최종 체결 미확인" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_late_call_exception_is_isolated(fill_on, caplog): + """지연 재확인 한 회차의 일반 예외는 그 회차만 건너뛴다(다음 회차에서 확정).""" + row = _row("31", 2, 20_000, 2, 0) + f, calls = _scripted([[]] * _n_initial() + [RuntimeError("EGW00201"), [row]]) + got = [] + + async def _on(info): + got.append(info) + + with patch.object(fr.korea_investment_api, "get_todays_orders", f): + await fr.schedule_v2("333333", "31", "BUY", 2, _on) + assert len(got) == 1 and got[0]["terminal"] is True + assert any("지연 재확인" in r.message and "RuntimeError" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_initial_timeout_keeps_seen_partial_row(monkeypatch, fill_on): + """즉시 확인이 시간초과로 끊겨도 그 전에 본 행(부분체결, 미종결)은 버리지 않는다.""" + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ATTEMPTS", 3) + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_TIMEOUT_SEC", 0.05) + partial = _row("44", 2, 9_900, 5, 3) + n = {"c": 0} + + async def _slow_after_first(*a, **k): + n["c"] += 1 + if n["c"] == 1: + return [partial] + await asyncio.sleep(5) # rate-limit sleep 모사 + return [] + + with patch.object(fr.korea_investment_api, "get_todays_orders", _slow_after_first): + info = await fr.fetch_fill_v2("444444", "44", "SELL", 5) + assert info is not None and info["filled_qty"] == 2 and info["terminal"] is False + + +@pytest.mark.asyncio +async def test_sell_late_confirmation_writes_slippage(monkeypatch, fill_on): + """종단: 매도 체결이 지연 재확인에서 확인되면 TradeHistory 에 slippage 가 기록된다.""" + from app.services.daytrading import exit_service as es + from app.services.daytrading.position_manager import day_trading_state + import app.services.db_worker as dbw + + updates = [] + + async def _upd(order_id, fields): + updates.append((order_id, fields)) + + row = _row("555", 4, 10_050, 4, 0) + f, _ = _scripted([[]] * _n_initial() + [[row]]) + monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _upd) + with patch.object(fr.korea_investment_api, "get_todays_orders", f), \ + patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ + patch.object(day_trading_state, "adjust_realized_pnl", lambda d: None): + t = es._schedule_sell_fill_reconcile_v2( + stock_code="555555", pos={"entry_price": 10_000.0, "quantity": 4}, order_id="555", + signal_price=10_100.0, quantity=4, entry_price=10_000.0, recorded_net=0.0, + count_daily_pnl=True, + ) + assert t is not None + await t + assert len(updates) == 1 + oid, fields = updates[0] + assert oid == "555" and fields.get("slippage") is not None + assert float(fields["executed_price"]) == pytest.approx(10_050.0) diff --git a/backend/tests/test_dt_orb_bar_closed_r6_20261010.py b/backend/tests/test_dt_orb_bar_closed_r6_20261010.py new file mode 100644 index 0000000..53eb293 --- /dev/null +++ b/backend/tests/test_dt_orb_bar_closed_r6_20261010.py @@ -0,0 +1,130 @@ +"""DT ORB 하드닝 라운드 6 회귀 테스트 (2026-10-10). + +[LOW] FULL_RANGE=False 정기 수집이 형성 중인 5번째 1분봉(예: 09:04:05 시점의 09:04 봉)의 +부분 고가·저가·거래량으로 하루 ORB 를 고정하고, 재기동 소급·지연 보충은 완성된 봉을 써서 +같은 날 같은 종목의 ORB 가 달라지던 결함. 이제 마지막으로 쓰는 봉 시각 + 60초 ≤ 현재일 때만 +확정한다(레인지 정의 '첫 N봉'은 불변). + +KIS 분봉 조회는 결정적 목, 주문 공통 종단은 BaseException 발생형으로 막는다(§4.1). +""" +from datetime import datetime +from unittest.mock import AsyncMock + +import pytest + +from app.core.config import settings +from app.services.day_trading_strategy import MinuteCandle +from app.services.daytrading import entry_service as ens +from app.services.daytrading.position_manager import day_trading_state + + +class OrderReached(BaseException): + pass + + +async def _order_reached(*a, **k): + raise OrderReached("safe_place_order 도달") + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch): + import app.services.trade_executor as te + monkeypatch.setattr(te, "safe_place_order", _order_reached) + saved_orb = dict(day_trading_state.orb_cache) + day_trading_state.orb_cache.clear() + ens.set_watchlist([]) + monkeypatch.setattr(settings, "DAY_TRADE_ORB_FULL_RANGE", False) + monkeypatch.setattr(ens.asyncio, "sleep", AsyncMock(return_value=None)) + monkeypatch.setattr(day_trading_state, "load_orb_cache", AsyncMock(return_value=0)) + yield + day_trading_state.orb_cache.clear() + day_trading_state.orb_cache.update(saved_orb) + ens.set_watchlist([]) + + +def _at(hh, mm, ss=0): + fake_now = datetime.now(ens._KST).replace(hour=hh, minute=mm, second=ss, microsecond=0) + + class _DT(datetime): + @classmethod + def now(cls, tz=None): + return fake_now if tz else fake_now.replace(tzinfo=None) + return _DT + + +def _bar(minute, high, low, vol, price=10_000.0): + d = datetime.now(ens._KST).strftime("%Y%m%d") + return MinuteCandle(date=d, time=f"09{minute:02d}00", open=price, high=high, low=low, + close=price, volume=vol, cum_volume=0, cum_amount=0.0) + + +def _complete_bars(n=40): + """09:00~ 완성 봉. 5번째(09:04) 봉은 최종적으로 고가 10,200·거래량 9,000.""" + out = [] + for m in range(n): + if m == 4: + out.append(_bar(m, 10_200.0, 9_950.0, 9_000)) + else: + out.append(_bar(m, 10_010.0 + m, 9_990.0, 1_000)) + return out + + +def _forming_feed(): + """09:04:05 시점: 09:04 봉이 막 생겨 부분값(고가 10,005·거래량 50).""" + bars = _complete_bars(4) + bars.append(_bar(4, 10_005.0, 9_995.0, 50)) + return bars + + +def test_opening_range_v2_waits_until_last_bar_closes(monkeypatch): + bars = _forming_feed() + monkeypatch.setattr(ens, "datetime", _at(9, 4, 59)) + assert ens._opening_range_v2(bars, False) is None + monkeypatch.setattr(ens, "datetime", _at(9, 5, 0)) + assert ens._opening_range_v2(_complete_bars(5), False) is not None + + +def test_unparseable_bar_time_is_not_finalized(monkeypatch): + monkeypatch.setattr(ens, "datetime", _at(10, 0)) + bars = _complete_bars(5) + bars[-1].time = "0904" # 길이 불일치 → 판정 불가 → 확정 보류 + # 창 필터는 문자열 비교라 "0904" 는 창 밖 → 4봉 → None. 판정 함수 자체도 False 여야 한다. + assert ens._orb_bar_closed_v2(bars[-1]) is False + + +@pytest.mark.asyncio +async def test_live_collect_skips_forming_bar_and_matches_restart_backfill(monkeypatch): + save = AsyncMock(return_value=True) + monkeypatch.setattr(day_trading_state, "save_orb_cache", save) + feeds = {"live": _forming_feed()} + monkeypatch.setattr(ens, "parse_minute_candles", lambda raw: raw) + monkeypatch.setattr(ens.korea_investment_api, "get_minute_chart_full_day", + AsyncMock(side_effect=lambda code: feeds[code])) + ens.set_watchlist([{"code": "live", "name": "L"}]) + + # 09:04:05 — 형성 중 봉으로 확정하지 않는다(저장도 없음) + monkeypatch.setattr(ens, "datetime", _at(9, 4, 5)) + await ens.collect_opening_ranges() + assert not day_trading_state.has_orb("live") + save.assert_not_awaited() + + # 09:05:00 — 09:04 봉 완성(09:05 봉이 형성 중이어도 첫 N봉만 쓴다) + feeds["live"] = _complete_bars(5) + [_bar(5, 10_500.0, 9_000.0, 7)] + monkeypatch.setattr(ens, "datetime", _at(9, 5, 0)) + await ens.collect_opening_ranges() + live = day_trading_state.get_orb("live") + assert live is not None + assert live.high == 10_200.0 + assert live.total_volume == 4 * 1_000 + 9_000 + save.assert_awaited() + + # 같은 날 10:00 재기동 소급(완성 봉 하루치)과 값이 같아야 한다 + day_trading_state.orb_cache.clear() + feeds["restart"] = _complete_bars(40) + ens.set_watchlist([{"code": "restart", "name": "R"}]) + monkeypatch.setattr(ens, "datetime", _at(10, 0)) + await ens.collect_opening_ranges() + r = day_trading_state.get_orb("restart") + assert (live.high, live.low, live.total_volume, live.avg_vol_per_min, live.open_price) == \ + (r.high, r.low, r.total_volume, r.avg_vol_per_min, r.open_price) + assert round(r.total_volume / r.avg_vol_per_min) == ens._ORB_FIRST_BARS diff --git a/backend/tests/test_dt_orphan_timestop_20261008.py b/backend/tests/test_dt_orphan_timestop_20261008.py new file mode 100644 index 0000000..1379cdb --- /dev/null +++ b/backend/tests/test_dt_orphan_timestop_20261008.py @@ -0,0 +1,456 @@ +"""DT 운영 관측 3건 회귀 테스트 (2026-10-08). + +1. 고아 DT 잔고 — Portfolio.strategy_type='daytrading' 수량은 있는데 인메모리·active DT 행이 + 없는 종목(10/06 KB금융: EOD 매도 접수 → 0체결 → ActiveTrade 만 closed)을 + close_stale_positions 가 감지해 같은 stale 경로로 전량 청산한다. + + 접수 후 0체결 종결이 확인되면 인메모리 포지션을 되살린다(재시도). +2. 시간손절 임계 -0.3% → -0.7% (비용·슬리피지 노이즈 위), reason 은 .2f. +3. 엔진 카드 '청산' 은 DT 청산만 센다(스윙 손절의 전략 간 쿨다운 제외). + +목킹 원칙: 주문은 공통 종단 `_execute_exit_impl`(stale 경로)·`safe_place_order` 에 건다. +호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 에서 계산한다. +""" +import asyncio +import inspect +from datetime import datetime, time, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services import day_trading_strategy as dts +from app.services.daytrading import exit_service as es +from app.services.daytrading import fill_reconciler as fr +from app.services.daytrading.position_manager import day_trading_state + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +class _SeqSession: + """execute() 호출 순서대로 결과를 돌려주는 가짜 세션(SELECT 전용).""" + + def __init__(self, results): + self._results = list(results) + self.calls = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *a, **k): + self.calls += 1 + rows = self._results.pop(0) if self._results else [] + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) + + async def commit(self): # 읽기 전용 경로 — 호출되면 실패 + raise MustNotBeCalled("commit") + + +def _sess(*results): + return lambda: _SeqSession(results) + + +def _balance(rows): + return AsyncMock(return_value={ + "rt_cd": "0", + "output1": [ + {"pdno": c, "hldg_qty": str(h), "ord_psbl_qty": str(s)} for c, h, s in rows + ], + "output2": [{}], + }) + + +KB = "105560" + + +def _pf(code=KB, qty=3, st="daytrading"): + return SimpleNamespace(stock_code=code, stock_name="KB금융", quantity=qty, + average_price=80_000.0, strategy_type=st) + + +def _th(code=KB, buy=3, sell=3, sell_status="CANCELLED", sell_exec=0, days_ago=2): + """[R1-1] DT 원장 — 기본값은 '매도 접수 후 0체결 종결'(순보유 = buy).""" + t = datetime.now().replace(microsecond=0) - timedelta(days=days_ago) + return [ + SimpleNamespace(stock_code=code, order_type="BUY", status="COMPLETED", + executed_quantity=buy, order_quantity=buy, created_at=t), + SimpleNamespace(stock_code=code, order_type="SELL", status=sell_status, + executed_quantity=sell_exec, order_quantity=sell, created_at=t), + ] + + +def _ev(code=KB, qty=3, days_ago=2, status="closed"): + return SimpleNamespace( + stock_code=code, stock_name="KB금융", quantity=qty, entry_price=80_000.0, + highest_since_entry=80_500.0, status=status, strategy_name="day_trade", + created_at=datetime.now().replace(microsecond=0) - timedelta(days=days_ago), + ) + + +@pytest.fixture(autouse=True) +def _clean(): + saved_pos = dict(day_trading_state.positions) + saved_closed = set(day_trading_state.closed_today) + saved_dt_exit = set(day_trading_state.dt_exited_today) + saved_pnl = day_trading_state.daily_pnl + day_trading_state.reset_if_new_day() + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.dt_exited_today.clear() + day_trading_state.daily_pnl = 0.0 + for coll in (es._stale_sell_attempted, es._stale_sell_failures, es._last_exit_order_outcome, + es._unresolved_exit_unknown, es._stale_cleaned, es._exit_tokens, + es._recent_dt_sell_finalized, es._manual_alerted): + coll.clear() + day_trading_state.exits_in_progress.clear() + from app.services.telegram_notifier import telegram_notifier + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ + patch.object(telegram_notifier, "notify_system_warning", AsyncMock()): + yield + day_trading_state.positions.clear() + day_trading_state.positions.update(saved_pos) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved_closed) + day_trading_state.dt_exited_today.clear() + day_trading_state.dt_exited_today.update(saved_dt_exit) + day_trading_state.daily_pnl = saved_pnl + es._recent_dt_sell_finalized.clear() + + +def _common(session_factory, balance, exec_mock): + return ( + patch.object(es, "is_market_open", return_value=True), + patch("app.core.database.AsyncSessionLocal", session_factory), + patch.object(es.korea_investment_api, "get_balance", balance), + patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "77600"}})), + patch.object(es, "_execute_exit_impl", exec_mock), + ) + + +async def _run(session_factory, balance, exec_mock): + p = _common(session_factory, balance, exec_mock) + with p[0], p[1], p[2], p[3], p[4]: + return await es.close_stale_positions() + + +# ═════════════════════════════════════════════════════════════════════════════ +# 1. 고아 DT 잔고 +# ═════════════════════════════════════════════════════════════════════════════ +# 쿼리 순서: ①DT active ②(stale 있을 때만) 비-DT ③Portfolio DT ④DT 근거 행 ④b DT 원장(R1-1) +# ⑤추가 비-DT + +@pytest.mark.asyncio +async def test_orphan_portfolio_dt_holding_is_sold_via_stale_path(): + """10/06 KB금융 재현: active 행은 closed, 인메모리 비어 있음, Portfolio 'daytrading' 3주.""" + exec_mock = AsyncMock(return_value={"stock_code": KB, "quantity": 3}) + out = await _run(_sess([], [_pf()], [_ev()], _th(), []), _balance([(KB, 3, 3)]), exec_mock) + assert out == [{"stock_code": KB, "quantity": 3}] + assert exec_mock.await_count == 1 + code, pos, price, reason, sell_pct = exec_mock.await_args.args + assert exec_mock.await_args.kwargs.get("is_stale") is True + assert code == KB and pos["quantity"] == 3 and sell_pct == 1.0 + assert "고아" in reason + # 당일 재진입 차단(stale 경로와 동일) + assert KB in day_trading_state.closed_today + + +@pytest.mark.asyncio +async def test_orphan_sell_qty_capped_by_swing_holding_and_evidence_qty(): + """§3.5 — 실보유 10, 비-DT active 7 → DT 가용 3. 근거 행 수량(3)·Portfolio(10) 중 최소.""" + swing = SimpleNamespace(stock_code=KB, quantity=7) + exec_mock = AsyncMock(return_value={"stock_code": KB}) + await _run(_sess([], [_pf(qty=10)], [_ev(qty=3)], _th(), [swing]), + _balance([(KB, 10, 10)]), exec_mock) + assert exec_mock.await_args.args[1]["quantity"] == 3 + + +@pytest.mark.asyncio +async def test_orphan_all_swing_owned_is_not_sold(): + swing = SimpleNamespace(stock_code=KB, quantity=3) + await _run(_sess([], [_pf()], [_ev()], _th(), [swing]), _balance([(KB, 3, 3)]), + _boom("_execute_exit_impl")) + + +@pytest.mark.asyncio +async def test_orphan_without_dt_evidence_is_not_sold(): + """'daytrading' 태그만 있고 최근 DT 행이 없으면 팔지 않는다(수동 매수 섞인 행 보호).""" + await _run(_sess([], [_pf()], []), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl")) + + +@pytest.mark.asyncio +async def test_orphan_with_zero_kis_holding_does_nothing(): + """실보유 0 — 매도도, ActiveTrade close 도 하지 않는다(닫을 active 행이 없다).""" + with patch.object(es, "enqueue_close_active_trade", _boom("enqueue_close_active_trade")): + out = await _run(_sess([], [_pf()], [_ev()], _th(), []), _balance([]), + _boom("_execute_exit_impl")) + assert out == [] + + +@pytest.mark.asyncio +async def test_orphan_kis_balance_failure_holds_sell(): + bal = AsyncMock(side_effect=RuntimeError("KIS down")) + out = await _run(_sess([], [_pf()], [_ev()], _th(), []), bal, _boom("_execute_exit_impl")) + assert out == [] + + +@pytest.mark.asyncio +async def test_orphan_grace_after_recent_dt_sell(): + """방금 DT 매도를 반영한 종목은 Portfolio/KIS 가 옛 수량일 수 있다 → 유예.""" + import time as _t + es._recent_dt_sell_finalized[KB] = _t.monotonic() + out = await _run(_sess([], [_pf()], [_ev()], []), _balance([(KB, 3, 3)]), + _boom("_execute_exit_impl")) + assert out == [] + + +@pytest.mark.asyncio +async def test_orphan_skipped_when_in_memory(): + day_trading_state.positions[KB] = {"entry_price": 80_000.0, "quantity": 3, + "entry_time": datetime.now()} + with patch.object(es.korea_investment_api, "get_balance", _boom("get_balance")): + p = _common(_sess([], [_pf()], [_ev()], []), _balance([]), _boom("_execute_exit_impl")) + with p[0], p[1], p[3], p[4], \ + patch.object(es.korea_investment_api, "get_balance", _boom("get_balance")): + assert await es.close_stale_positions() == [] + + +@pytest.mark.asyncio +async def test_orphan_detection_can_be_disabled(monkeypatch): + monkeypatch.setattr(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", False) + out = await _run(_sess([], [_pf()], [_ev()], []), _balance([(KB, 3, 3)]), + _boom("_execute_exit_impl")) + assert out == [] + + +# ── 당일 active 행 인메모리 유실 → 복원 ──────────────────────────────────── + +def _today_row(code="222222", qty=5): + return SimpleNamespace( + stock_code=code, stock_name="T", strategy_name="day_trade", status="active", + entry_price=10_000.0, stop_loss=9_700.0, take_profit=10_450.0, quantity=qty, + highest_since_entry=10_100.0, created_at=datetime.now().replace(microsecond=0), + entry_reason="", order_id="1", remaining_pct=None, + ) + + +@pytest.mark.asyncio +async def test_today_active_row_missing_in_memory_is_restored_not_sold(): + # ①DT active=[당일 행] ③Portfolio=[] ⑤추가 비-DT=[] + out = await _run(_sess([_today_row()], [], []), _balance([("222222", 5, 5)]), + _boom("_execute_exit_impl")) + assert out == [] + pos = day_trading_state.positions["222222"] + assert pos["quantity"] == 5 and pos["stop_loss"] == 9_700.0 + + +@pytest.mark.asyncio +async def test_today_active_row_not_restored_when_closed_today(): + # [R2 하드닝 2026-10-09] 복원 차단 근거는 DT 전량 청산 기록(remove_position 의 dt_exited_today). + # closed_today 만 있는 경우(불명 매수 접수·0체결 복원)는 test_dt_restore_closed_today_r2h_20261009 참조. + day_trading_state.closed_today.add("222222") + day_trading_state.dt_exited_today.add("222222") + await _run(_sess([_today_row()], [], []), _balance([("222222", 5, 5)]), + _boom("_execute_exit_impl")) + assert "222222" not in day_trading_state.positions + + +@pytest.mark.asyncio +async def test_today_active_row_not_restored_when_holding_short(): + await _run(_sess([_today_row(qty=5)], [], []), _balance([("222222", 2, 2)]), + _boom("_execute_exit_impl")) + assert "222222" not in day_trading_state.positions + + +# ── 접수 후 0체결 종결 → 인메모리 복원 ────────────────────────────────────── + +@pytest.fixture +def exit_env(): + import app.services.trade_executor as te + import app.services.portfolio_cache_bridge as pcb + cap = {"updates": [], "history": []} + + async def _upd(order_id, fields): + cap["updates"].append((order_id, fields)) + + async def _hist(payload): + cap["history"].append(payload) + + with patch.object(te, "safe_place_order", + AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "0000123"}})), \ + patch.object(es, "enqueue_trade_history", _hist), \ + patch.object(es, "enqueue_close_active_trade", AsyncMock()), \ + patch("app.services.db_worker.enqueue_active_trade_upsert", AsyncMock()), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", _upd), \ + patch.object(pcb, "reflect_sell", AsyncMock()), \ + patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0), \ + patch.object(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0): + yield cap + + +def _pos(qty=10): + return {"stock_name": "테스트", "entry_price": 10_000.0, "quantity": qty, + "stop_loss": 9_700.0, "target_price": 10_450.0, + "highest_price": 10_000.0, "order_id": "0000999", "entry_time": datetime.now()} + + +@pytest.mark.asyncio +async def test_eod_sell_accepted_then_zero_fill_restores_position(exit_env): + day_trading_state.positions["666666"] = _pos(10) + rows = [{"odno": "123", "pdno": "666666", "ord_qty": "10", "tot_ccld_qty": "0", + "avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}] + with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): + res = await es._execute_exit("666666", day_trading_state.positions["666666"], + 9_900.0, "EOD 강제 청산 (15:20)") + assert res is not None # 접수 시점엔 청산으로 반영됐다(기존 동작) + await asyncio.gather(*list(fr._pending_tasks)) + # 인메모리 복원 → 다음 EOD 사이클 force_close_all 이 재시도 + assert day_trading_state.positions["666666"]["quantity"] == 10 + assert "666666" not in day_trading_state.dt_exited_today + # 당일 손익은 체결 0 이므로 원복 + assert day_trading_state.daily_pnl == pytest.approx(0.0) + oid, upd = exit_env["updates"][-1] + assert oid == "0000123" and upd["status"] == "CANCELLED" and upd["executed_quantity"] == 0 + # 그레이스 해제(고아 감지 즉시 가능) + assert "666666" not in es._recent_dt_sell_finalized + + +@pytest.mark.asyncio +async def test_stale_sell_zero_fill_clears_reorder_block(exit_env): + today = datetime.now().date().isoformat() + rows = [{"odno": "123", "pdno": "777777", "ord_qty": "10", "tot_ccld_qty": "0", + "avg_prvs": "0", "rmn_qty": "0", "rjct_qty": "10"}] + es._stale_sell_attempted.add((today, "777777")) + with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): + await es._execute_exit_stale("777777", _pos(10), 9_500.0, "전일 미청산 포지션 강제 청산") + await asyncio.gather(*list(fr._pending_tasks)) + assert (today, "777777") not in es._stale_sell_attempted + assert "777777" not in day_trading_state.positions # 전일분은 close_stale 가 다시 잡는다 + assert day_trading_state.daily_pnl == 0.0 + + +@pytest.mark.asyncio +async def test_full_fill_keeps_existing_reconcile_behavior(exit_env): + day_trading_state.positions["888888"] = _pos(10) + rows = [{"odno": "123", "tot_ccld_qty": "10", "avg_prvs": "10080"}] + with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): + await es._execute_exit("888888", day_trading_state.positions["888888"], 10_100.0, "트레일링") + await asyncio.gather(*list(fr._pending_tasks)) + assert "888888" not in day_trading_state.positions + _, upd = exit_env["updates"][-1] + assert float(upd["executed_price"]) == pytest.approx(10_080.0) + assert "status" not in upd + + +# ── 스케줄러 배선 ────────────────────────────────────────────────────────── + +def test_scheduler_runs_stale_orphan_check_at_startup_without_restore(): + """복원 0건이어도 기동 시 전일·고아 점검을 한다 — 'if restored:' 블록 밖(복원 try 이후).""" + from app.services.daytrading import scheduler as sched + src = inspect.getsource(sched.daytrading_scheduler) + head = src[:src.index("while True:")] + restore_fail = head.index("시작 시 포지션 복원 실패") + assert head.index("_dt_exit.close_stale_positions()") > restore_fail + + +def test_scheduler_offhours_branch_checks_without_positions_guard(): + from app.services.daytrading import scheduler as sched + src = inspect.getsource(sched.daytrading_scheduler) + tail = src[src.index("# 그 외 시간"):] + tail = tail[:tail.index("except Exception as e:")] + assert "check_and_exit_positions()" in tail + assert "if day_trading_state.positions:" not in tail + eod = src[src.index("if is_eod_flat_time():"):] + eod = eod[:eod.index("continue")] + assert "_dt_exit.close_stale_positions()" in eod + + +# ═════════════════════════════════════════════════════════════════════════════ +# 2. 시간손절 임계 +# ═════════════════════════════════════════════════════════════════════════════ + +def test_time_stop_threshold_is_negative(): + """임계는 손실 쪽이어야 한다(양수면 수익 중 포지션까지 시간손절).""" + assert settings.DAY_TRADE_TIME_STOP_MIN_GAIN < 0 + + +def _exit_at(gain): + from app.core.timeutil import now_kst + entry = 10_000.0 + return dts.check_day_trade_exit( + entry_price=entry, current_price=entry * (1 + gain), stop_loss=entry * 0.5, + target_price=entry * 2, highest_since_entry=entry, candles_5min=[], + current_time=time(10, 0), + entry_time=now_kst() - timedelta(minutes=settings.DAY_TRADE_TIME_STOP_MINUTES + 1), + ) + + +def test_time_stop_not_triggered_by_cost_sized_loss(): + # 임계의 절반 손실(비용·슬리피지 수준) — 시간손절 아님 + sig = _exit_at(settings.DAY_TRADE_TIME_STOP_MIN_GAIN / 2) + assert sig is None or not dts.is_time_stop_exit_reason(sig.reason) + + +def test_time_stop_triggered_below_threshold_with_2dp_reason(): + g = settings.DAY_TRADE_TIME_STOP_MIN_GAIN - 0.0001 + sig = _exit_at(g) + assert sig is not None and dts.is_time_stop_exit_reason(sig.reason) + assert f"< {settings.DAY_TRADE_TIME_STOP_MIN_GAIN * 100:.2f}%" in sig.reason + # .1f 반올림이었다면 "-0.7% < -0.7%" 처럼 보였다 — 수익률도 .2f + assert f"수익={g * 100:.2f}%" in sig.reason + + +# ═════════════════════════════════════════════════════════════════════════════ +# 3. 엔진 카드 '청산' = DT 청산만 +# ═════════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_status_closed_count_excludes_swing_cooldown_codes(): + from app.api.endpoints import day_trading as dt_api + day_trading_state.closed_today.update({"015760", "006260"}) # 스윙 손절 쿨다운(한국전력·LS) + async with day_trading_state.trade_lock: + for c in ("111111", "222222"): + day_trading_state.positions[c] = {"quantity": 1} + day_trading_state.remove_position(c) + out = await dt_api.get_status() + assert out["closed_today"] == 2 + assert out["reentry_blocked_today"] == 4 + + +@pytest.mark.asyncio +async def test_dt_exited_today_persisted_and_restored(): + from app.services.daytrading.position_manager import DayTradingState + saved = {} + + async def _setex(key, ttl, payload): + saved["p"] = payload + return True + + async def _get(key): + return saved.get("p") + + a = DayTradingState() + a.reset_if_new_day() + a.dt_exited_today.add("111111") + a.closed_today.update({"111111", "015760"}) + with patch("app.core.redis.setex_json", _setex), patch("app.core.redis.get_json", _get): + assert await a.save_daily_state() + b = DayTradingState() + b.reset_if_new_day() + assert await b.load_daily_state() + assert b.dt_exited_today == {"111111"} + assert b.closed_today == {"111111", "015760"} diff --git a/backend/tests/test_dt_orphan_today_r6_20261010.py b/backend/tests/test_dt_orphan_today_r6_20261010.py new file mode 100644 index 0000000..a48f653 --- /dev/null +++ b/backend/tests/test_dt_orphan_today_r6_20261010.py @@ -0,0 +1,322 @@ +"""DT 하드닝 라운드 6 — 당일 매수분 고아 청산 손익의 당일 집계 (2026-10-10, daytrading/exit_service.py). + +R6-1 당일 DT 매수 후 인메모리·active 행이 모두 유실돼 고아로 감지된 포지션은 + 전일·고아(stale) 경로(실보유·타전략 차감·재시도)를 그대로 타되, + 실현손익은 당일 daily_pnl·loss_streak_today 에 넣는다(§7 의 제외 예외는 '전일' 포지션뿐). + 청산 사유 접두사도 분리해 재기동 원장 재구성에서 당일 손익으로 포함된다. + +목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`)에 걸고, 호출되면 안 되는 +경로는 BaseException 발생형. 기대값은 settings·모듈 상수에서 계산한다. +""" +import asyncio +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.daytrading import exit_service as es +from app.services.daytrading import position_manager as pm +from app.services.daytrading.position_manager import day_trading_state + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +class _SeqSession: + def __init__(self, results): + self._results = list(results) + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *a, **k): + rows = self._results.pop(0) if self._results else [] + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) + + async def commit(self): + raise MustNotBeCalled("commit") + + +def _sess(*results): + return lambda: _SeqSession(results) + + +def _balance(rows): + return AsyncMock(return_value={ + "rt_cd": "0", + "output1": [{"pdno": c, "hldg_qty": str(h), "ord_psbl_qty": str(s)} for c, h, s in rows], + "output2": [{}], + }) + + +CODE = "005930" + + +def _today_at(h, m): + return datetime.combine(es._kst_now().date(), datetime.min.time()).replace(hour=h, minute=m) + + +def _pf(qty=3): + return SimpleNamespace(stock_code=CODE, stock_name="삼성전자", quantity=qty, + average_price=70_000.0, strategy_type="daytrading") + + +def _ev(qty=3, created_at=None): + return SimpleNamespace( + stock_code=CODE, stock_name="삼성전자", quantity=qty, entry_price=70_000.0, + highest_since_entry=70_500.0, status="closed", strategy_name="day_trade", + created_at=created_at or (datetime.now().replace(microsecond=0) - timedelta(days=3)), + ) + + +def _th(side, qty, created_at, status="COMPLETED", executed=None): + return SimpleNamespace(stock_code=CODE, order_type=side, status=status, + executed_quantity=qty if executed is None else executed, + order_quantity=qty, created_at=created_at) + + +@pytest.fixture(autouse=True) +def _clean(): + from app.services.telegram_notifier import telegram_notifier + saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today), + set(day_trading_state.dt_exited_today), day_trading_state.daily_pnl, + day_trading_state.loss_streak_today, es._stale_realized_pnl) + day_trading_state.reset_if_new_day() + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.dt_exited_today.clear() + day_trading_state.daily_pnl = 0.0 + day_trading_state.loss_streak_today = 0 + for coll in (es._stale_sell_attempted, es._stale_sell_failures, es._last_exit_order_outcome, + es._unresolved_exit_unknown, es._stale_cleaned, es._exit_tokens, + es._recent_dt_sell_finalized, es._manual_alerted, es._rest_price_seen): + coll.clear() + day_trading_state.exits_in_progress.clear() + tg = AsyncMock() + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ + patch.object(telegram_notifier, "notify_system_warning", tg): + yield tg + day_trading_state.positions.clear() + day_trading_state.positions.update(saved[0]) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved[1]) + day_trading_state.dt_exited_today.clear() + day_trading_state.dt_exited_today.update(saved[2]) + day_trading_state.daily_pnl = saved[3] + day_trading_state.loss_streak_today = saved[4] + es._stale_realized_pnl = saved[5] + for coll in (es._unresolved_exit_unknown, es._manual_alerted, es._rest_price_seen, + es._recent_dt_sell_finalized, es._stale_sell_attempted, es._stale_cleaned): + coll.clear() + + +async def _run(session_factory, balance, exec_mock): + with patch.object(es, "is_market_open", return_value=True), \ + patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True), \ + patch("app.core.database.AsyncSessionLocal", session_factory), \ + patch.object(es.korea_investment_api, "get_balance", balance), \ + patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "69000"}})), \ + patch.object(es, "_execute_exit_impl", exec_mock): + out = await es.close_stale_positions() + await asyncio.sleep(0) + await asyncio.sleep(0) + return out + + +# 쿼리 순서: ①DT active ③Portfolio DT ④DT 근거 행 ④b DT 원장 ⑤추가 비-DT + +# ═══ 감지: 당일 순매수 → orphan_today ═══════════════════════════════════════════ + +def test_sanity_reason_prefix_not_treated_as_stale_in_rebuild(): + """새 사유 접두사는 전일·고아 제외 접두사와 겹치지 않는다(겹치면 원장 재구성이 다시 뺀다).""" + assert not es._ORPHAN_TODAY_EXIT_REASON_PREFIX.startswith(pm._STALE_EXIT_REASON_PREFIXES) + + +@pytest.mark.asyncio +async def test_today_buy_orphan_routes_to_stale_terminus_with_today_flag_and_reason(): + """근거 ActiveTrade 는 3일 전 행이지만 오늘 DT 매수 3주(순체결 +3) → 당일 고아. + stale 공통 종단을 타되(실보유·타전략 차감 유지) 사유는 당일 접두사, 진입일은 실제 매수일.""" + buy_at = _today_at(9, 5) + th = [_th("BUY", 3, buy_at)] + exec_mock = AsyncMock(return_value={"stock_code": CODE}) + await _run(_sess([], [_pf()], [_ev()], th, []), _balance([(CODE, 3, 3)]), exec_mock) + assert exec_mock.await_count == 1 + args, kwargs = exec_mock.await_args.args, exec_mock.await_args.kwargs + pos, reason = args[1], args[3] + assert kwargs.get("is_stale") is True + assert pos[es._ORPHAN_TODAY_KEY] is True + assert pos["entry_time"] == buy_at + assert reason.startswith(es._ORPHAN_TODAY_EXIT_REASON_PREFIX) + assert f"진입일 {buy_at.date()}" in reason + assert not reason.startswith(pm._STALE_EXIT_REASON_PREFIXES) + + +@pytest.mark.asyncio +async def test_past_buy_orphan_keeps_stale_prefix_and_no_today_flag(): + """매수가 전일 이전뿐이면 기존 '전일·고아' 경로 그대로(당일 손익 제외).""" + buy_at = _today_at(9, 5) - timedelta(days=2) + th = [_th("BUY", 3, buy_at), _th("SELL", 3, buy_at + timedelta(hours=6), "CANCELLED", executed=0)] + exec_mock = AsyncMock(return_value={"stock_code": CODE}) + await _run(_sess([], [_pf()], [_ev()], th, []), _balance([(CODE, 3, 3)]), exec_mock) + pos, reason = exec_mock.await_args.args[1], exec_mock.await_args.args[3] + assert pos[es._ORPHAN_TODAY_KEY] is False + assert pos["entry_time"] == buy_at # 근거 행(3일 전)이 아니라 실제 마지막 매수 + assert reason.startswith("DT 고아 잔고 청산") + + +@pytest.mark.asyncio +async def test_today_orphan_still_capped_by_other_strategy_share(): + """당일 고아여도 §3.5 차감은 그대로 — 실보유 6 − 스윙 3 = DT 3 만 판다.""" + synced = SimpleNamespace(stock_code=CODE, quantity=3) + th = [_th("BUY", 3, _today_at(9, 5))] + exec_mock = AsyncMock(return_value={"stock_code": CODE}) + await _run(_sess([], [_pf(qty=6)], [_ev()], th, [synced]), _balance([(CODE, 6, 6)]), exec_mock) + assert exec_mock.await_args.args[1]["quantity"] == 3 + assert exec_mock.await_args.args[1][es._ORPHAN_TODAY_KEY] is True + + +def test_last_buy_helper_splits_today_and_past(): + t0 = _today_at(9, 1) + rows = [_th("BUY", 1, t0 - timedelta(days=1)), _th("BUY", 1, t0), + _th("BUY", 1, t0 + timedelta(minutes=5), status="PENDING"), + _th("SELL", 1, t0 + timedelta(minutes=9))] + out = es._last_completed_dt_buy_at_v2(rows, t0.date()) + assert out[(CODE, True)] == t0 + assert out[(CODE, False)] == t0 - timedelta(days=1) + + +# ═══ 반영: _finalize_exit_impl (stale 플래그로 들어와도 당일 집계) ══════════════ + +def _orphan_pos(today_flag: bool, qty=10, entry=10_000.0): + return {"stock_name": "삼성전자", "strategy": "day_trade", "entry_price": entry, + "quantity": qty, "highest_price": entry, "entry_time": _today_at(9, 5), + "reason": "", "order_id": "", "orphan": True, es._ORPHAN_TODAY_KEY: today_flag} + + +async def _finalize(pos, price): + import app.services.portfolio_cache_bridge as pcb + sched = AsyncMock() + with patch.object(es, "enqueue_trade_history", AsyncMock()), \ + patch.object(es, "enqueue_close_active_trade", AsyncMock()), \ + patch.object(es, "_schedule_sell_fill_reconcile_v2", lambda **k: None), \ + patch.object(es, "_in_closing_auction_impl", return_value=False), \ + patch.object(pcb, "reflect_sell", sched), \ + patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()) as rec, \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()) as save: + out = await es._finalize_exit_impl( + CODE, pos, price, f"{es._ORPHAN_TODAY_EXIT_REASON_PREFIX} (테스트)", 1.0, + {"output": {"ODNO": "0000777"}}, int(pos["quantity"]), int(pos["quantity"]), True, + count_daily_pnl=False, + ) + return out, rec, save + + +@pytest.mark.asyncio +async def test_today_orphan_loss_counts_in_daily_pnl_and_streak(): + stale_before = es._stale_realized_pnl + out, rec, save = await _finalize(_orphan_pos(True), 9_700.0) + assert out["profit_loss"] < 0 + assert day_trading_state.daily_pnl == pytest.approx(out["profit_loss"]) + assert day_trading_state.loss_streak_today == 1 + assert es._stale_realized_pnl == pytest.approx(stale_before) + # loss_tracker 는 기존대로 기록 + save_to_db (§5.1) + assert rec.await_count == 1 and save.await_count == 1 + assert CODE in day_trading_state.closed_today # 재진입 차단 유지 + + +@pytest.mark.asyncio +async def test_past_orphan_loss_still_excluded_from_daily_pnl(): + stale_before = es._stale_realized_pnl + out, _, _ = await _finalize(_orphan_pos(False), 9_700.0) + assert day_trading_state.daily_pnl == 0.0 + assert day_trading_state.loss_streak_today == 0 + assert es._stale_realized_pnl == pytest.approx(stale_before + out["profit_loss"]) + + +@pytest.mark.asyncio +async def test_today_orphan_loss_triggers_daily_limit(): + """당일 고아 손실로 일일 손실한도(settings)를 넘으면 is_daily_loss_exceeded 가 발동한다. + (예전에는 stale 경로라 daily_pnl 이 0 으로 남아 한도가 발동하지 않았다.)""" + from app.services.day_trading_strategy import is_daily_loss_exceeded + total = 1_000_000.0 + loss_ratio = abs(float(settings.DAY_TRADE_DAILY_LOSS_LIMIT)) + qty, entry = 100, 10_000.0 + price = entry - (total * loss_ratio * 2) / qty # 한도의 약 2배 손실 + await _finalize(_orphan_pos(True, qty=qty, entry=entry), price) + assert is_daily_loss_exceeded(total, day_trading_state.daily_pnl) + day_trading_state.daily_pnl = 0.0 + await _finalize(_orphan_pos(False, qty=qty, entry=entry), price) + assert not is_daily_loss_exceeded(total, day_trading_state.daily_pnl) + + +# ═══ 체결 확인: 0체결 종결 시 당일 손익 되돌림, 인메모리 복원 없음 ═══════════════ + +@pytest.mark.asyncio +async def test_today_orphan_unfilled_reverts_daily_pnl_without_memory_restore(): + captured = {} + + def _sched(code, order_id, side, qty, cb): + captured["cb"] = cb + return object() + + day_trading_state.daily_pnl = -3_000.0 + pos = _orphan_pos(True, qty=3) + upsert = AsyncMock() + with patch.object(es.fill_reconciler, "schedule_v2", _sched), \ + patch("app.services.db_worker.enqueue_active_trade_upsert", upsert), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()): + es._schedule_sell_fill_reconcile_v2( + stock_code=CODE, pos=pos, order_id="0000778", signal_price=9_000.0, quantity=3, + entry_price=10_000.0, recorded_net=-3_000.0, count_daily_pnl=False, + ) + await captured["cb"]({"terminal": True, "filled_qty": 0, "avg_price": 0}) + assert day_trading_state.daily_pnl == pytest.approx(0.0) + # 인메모리로 직접 되살리지는 않는다(손절가 없는 사본) + assert CODE not in day_trading_state.positions + # [R8-1] 대신 ActiveTrade(day_trade, active)를 당일 진입 시각으로 되살려 당일 행 복원 경로에 맡긴다 + assert upsert.await_count == 1 + payload = upsert.await_args.args[0] + assert payload["strategy_name"] == "day_trade" and payload["status"] == "active" + assert payload["quantity"] == 3 + assert payload["created_at"] == pos["entry_time"] + assert CODE in day_trading_state.dt_reheld_today # 당일 행 복원 보류(restore_block) 해제 + day_trading_state.dt_reheld_today.discard(CODE) + # stale 재청산 표식이 지워져 close_stale_positions 가 다시 잡는다 + assert (es._kst_now().date().isoformat(), CODE) not in es._stale_sell_attempted + + +# ═══ 재기동 원장 재구성: 당일 고아 사유는 당일 손익에 포함 ═══════════════════════ + +def _row(i, order_type, net=None, reason=""): + return SimpleNamespace( + id=i, stock_code=CODE, order_type=order_type, status="COMPLETED", exit_reason=reason, + net_profit_loss=net, strategy_type="daytrading", created_at=_today_at(9, 10 + i), + executed_quantity=3, order_quantity=3, + ) + + +def test_rebuild_counts_today_orphan_reason_but_excludes_stale_orphan(): + today_rows = [_row(0, "BUY"), _row(1, "SELL", -2_000.0, + f"{es._ORPHAN_TODAY_EXIT_REASON_PREFIX} (진입일 x)")] + r = pm._rebuild_daily_state_from_rows(today_rows) + assert r["daily_pnl"] == pytest.approx(-2_000.0) + assert r["loss_streak_today"] == 1 + assert r["stale_excluded"] == 0 + + stale_rows = [_row(0, "BUY"), _row(1, "SELL", -2_000.0, "DT 고아 잔고 청산 (진입일 x)")] + r2 = pm._rebuild_daily_state_from_rows(stale_rows) + assert r2["daily_pnl"] == 0.0 + assert r2["stale_excluded"] == 1 diff --git a/backend/tests/test_dt_restore_closed_today_r2h_20261009.py b/backend/tests/test_dt_restore_closed_today_r2h_20261009.py new file mode 100644 index 0000000..08196e9 --- /dev/null +++ b/backend/tests/test_dt_restore_closed_today_r2h_20261009.py @@ -0,0 +1,283 @@ +"""[2026-10-09 하드닝 루프 R2] 재진입 차단(closed_today) ≠ 청산 완료 — 복원 검증 회귀 테스트. + +결함 (MEDIUM): verify_restored_today_positions_v2 / _detect_dt_orphans_impl / _restore_lost_today_positions_impl +가 closed_today 를 '이미 청산됨'으로 해석해, (a) 불명 매수가 접수로 확정된 포지션, (b) 전량 매도가 +0체결·부분체결로 종결돼 _on_unfilled 가 되살린 포지션을 재기동 시 관리에서 빼고 장중 복원도 막았다 +→ 손절·트레일링·시간손절·15:20 EOD 없이 오버나이트. +수정: '청산 완료'는 dt_exited_today(remove_position) − dt_reheld_today(청산 뒤 보유 재개) 기준. +closed_today 만 있는 종목은 기존 KIS 실보유 − 타전략(§3.5) 규칙으로 판정한다. +""" +import inspect +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.daytrading import exit_service as xs +from app.services.daytrading import position_manager as pm +from app.services.daytrading.position_manager import DayTradingState + + +class _MustNotCall(BaseException): + """호출되면 안 되는 경로 — 프로덕션 except Exception 에 삼켜지지 않게 BaseException.""" + + +class _Result: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return self + + def all(self): + return list(self._rows) + + +class _Session: + def __init__(self, rows): + self._rows = rows + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *_a, **_k): + return _Result(self._rows) + + +def _factory(rows): + return lambda: _Session(rows) + + +def _no_db(): + raise _MustNotCall("DB 조회가 일어나면 안 된다") + + +async def _no_kis(): + raise _MustNotCall("KIS 잔고 조회가 일어나면 안 된다") + + +def _holdings(mapping): + async def _f(): + return {c: {"hldg": q, "sellable": q} for c, q in mapping.items()} + return _f + + +def _today_at(h=9, m=10): + return datetime.combine(pm._today_kst(), datetime.min.time()).replace(hour=h, minute=m) + + +def _pos(qty, restored=True): + return { + "stock_name": "T", "strategy": "day_trade", "entry_price": 10_000.0, + "stop_loss": 9_800.0, "target_price": 10_300.0, "quantity": qty, + "highest_price": 10_000.0, "entry_time": _today_at(), + "reason": "", "order_id": "", **({"restored_from_db": True} if restored else {}), + } + + +@pytest.fixture(autouse=True) +def _notify(): + m = AsyncMock(return_value=None) + with patch.object(pm, "_notify_daily_state_impl", m): + yield m + + +@pytest.fixture +def redis_store(): + """Redis 를 인메모리 dict 로 대체 (실호출 방지).""" + store: dict = {} + + async def _setex(key, _ttl, value): + store[key] = value + return True + + async def _get(key): + return store.get(key) + + with patch("app.core.redis.setex_json", _setex), patch("app.core.redis.get_json", _get): + yield store + + +async def _restart(old: DayTradingState, rows_qty: dict) -> DayTradingState: + """같은 날 재기동 시뮬레이션: Redis 복원 + restore_from_db(active 행) 결과를 positions 에 주입.""" + await old.save_daily_state() + st = DayTradingState() + st.begin_restore() + assert await st.load_daily_state() is True + for c, q in rows_qty.items(): + st.positions[c] = _pos(q) + return st + + +# ── (a) 불명 매수 → 접수 확정 ───────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_unknown_buy_accepted_survives_restart_when_kis_holds(redis_store, _notify): + old = DayTradingState() + old.begin_restore() + old.closed_today.add("U00001") # entry_service 불명 매수 등록(재진입 차단) + old.add_position("U00001", _pos(5, restored=False)) # 접수 확정 → 포지션 생성 + st = await _restart(old, {"U00001": 5}) + assert "U00001" in st.closed_today # 재진입 차단은 유지 + r = await st.verify_restored_today_positions_v2( + session_factory=_factory([]), holdings_fetcher=_holdings({"U00001": 5}), + ) + assert "U00001" in st.positions and r["held"] == [] + _notify.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_closed_today_only_still_applies_kis_minus_other_rule(): + """closed_today 만으로 통과시키지 않는다 — §3.5 실보유 − 타전략 규칙은 그대로.""" + st = DayTradingState() + st.begin_restore() + st.closed_today.add("U00002") + st.positions["U00002"] = _pos(5) + swing = [SimpleNamespace(stock_code="U00002", quantity=3)] + r = await st.verify_restored_today_positions_v2( + session_factory=_factory(swing), holdings_fetcher=_holdings({"U00002": 5}), + ) + assert r["held"] == ["U00002"] and "U00002" not in st.positions + + +@pytest.mark.asyncio +async def test_swing_cooldown_closed_today_does_not_drop_dt_position(): + """스윙 손절(전략 간 쿨다운)로 closed_today 에 든 종목의 DT 포지션도 관리 대상이다.""" + st = DayTradingState() + st.begin_restore() + st.closed_today.add("W00001") + st.positions["W00001"] = _pos(4) + r = await st.verify_restored_today_positions_v2( + session_factory=_factory([]), holdings_fetcher=_holdings({"W00001": 4}), + ) + assert "W00001" in st.positions and r["held"] == [] + + +# ── (b) 전량 매도 0체결/부분체결 종결 → _on_unfilled 복원 ───────────────────── + +@pytest.mark.asyncio +async def test_unfilled_full_exit_restored_survives_restart(redis_store): + old = DayTradingState() + old.begin_restore() + old.add_position("F00001", _pos(6, restored=False)) + old.remove_position("F00001") # 접수 시 _finalize_exit_impl + _np = _pos(6, restored=False) + old.add_position("F00001", _np) # _on_unfilled 복원 + assert "F00001" in old.dt_reheld_today + st = await _restart(old, {"F00001": 6}) + r = await st.verify_restored_today_positions_v2( + session_factory=_factory([]), holdings_fetcher=_holdings({"F00001": 6}), + ) + assert "F00001" in st.positions and r["held"] == [] + + +@pytest.mark.asyncio +async def test_partial_fill_restored_keeps_dt_exited_but_survives(redis_store): + """부분체결 종결은 dt_exited_today 가 남는다(discard 안 함) — reheld 로 통과해야 한다.""" + old = DayTradingState() + old.begin_restore() + old.add_position("F00002", _pos(10, restored=False)) + old.remove_position("F00002") + old.add_position("F00002", _pos(6, restored=False)) # 미체결 6주 복원 + st = await _restart(old, {"F00002": 6}) + assert "F00002" in st.dt_exited_today and "F00002" in st.dt_reheld_today + r = await st.verify_restored_today_positions_v2( + session_factory=_factory([]), holdings_fetcher=_holdings({"F00002": 6}), + ) + assert "F00002" in st.positions and r["held"] == [] + + +@pytest.mark.asyncio +async def test_reheld_then_exited_again_is_blocked_without_kis(redis_store, _notify): + old = DayTradingState() + old.begin_restore() + old.add_position("F00003", _pos(6, restored=False)) + old.remove_position("F00003") + old.add_position("F00003", _pos(6, restored=False)) + old.remove_position("F00003") # 재시도 매도로 다시 전량 청산 + assert "F00003" not in old.dt_reheld_today + st = await _restart(old, {"F00003": 6}) + r = await st.verify_restored_today_positions_v2(session_factory=_no_db, holdings_fetcher=_no_kis) + assert r["held"] == ["F00003"] and "F00003" not in st.positions + _notify.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_legacy_payload_without_dt_exited_key_blocks_conservatively(redis_store): + """구버전 페이로드(dt_exited_today 키 없음) — DT 청산과 재진입 차단을 구분 못 하므로 보류(구 동작).""" + today = pm._today_kst() + redis_store[f"daytrading:daily:{today}"] = { + "date": today.isoformat(), "daily_pnl": 0.0, "loss_streak_today": 0, + "closed_today": ["L00001"], "last_total_assets": 1e7, + } + st = DayTradingState() + st.begin_restore() + assert await st.load_daily_state() is True + st.positions["L00001"] = _pos(3) + r = await st.verify_restored_today_positions_v2(session_factory=_no_db, holdings_fetcher=_no_kis) + assert r["held"] == ["L00001"] + + +def test_on_unfilled_restores_through_add_position(): + """reheld 표시는 add_position 에서 한다 — _on_unfilled 가 그 경로를 쓰는지 고정.""" + src = inspect.getsource(xs._schedule_sell_fill_reconcile_v2) + i = src.index("async def _on_unfilled") + assert "day_trading_state.add_position(stock_code, _np)" in src[i:i + 6000] + + +def test_rebuild_marks_partial_fill_as_reheld(): + """Redis 미스 원장 재구성: 전량 매도가 부분체결(4/10)로 종결 → 순체결 6>0 → reheld.""" + def _r(i, ot, oq, eq, st="COMPLETED", net=None): + return SimpleNamespace( + id=i, stock_code="R00001", order_type=ot, status=st, exit_reason="", order_quantity=oq, + executed_quantity=eq, net_profit_loss=net, strategy_type="daytrading", + created_at=_today_at(9, 10 + i), + ) + rows = [_r(0, "BUY", 10, 10), _r(1, "SELL", 10, 4, net=-100.0)] + out = pm._rebuild_daily_state_from_rows(rows) + assert out["dt_exited_today"] == {"R00001"} and out["dt_reheld_today"] == {"R00001"} + full = [_r(0, "BUY", 10, 10), _r(1, "SELL", 10, 10, net=-100.0)] + assert pm._rebuild_daily_state_from_rows(full)["dt_reheld_today"] == set() + + +# ── 장중 복원 경로 (exit_service) ───────────────────────────────────────────── + +def _row(code, qty): + return SimpleNamespace( + stock_code=code, stock_name="T", strategy_name="day_trade", quantity=qty, + entry_price=10_000, stop_loss=9_800, take_profit=10_300, highest_since_entry=None, + created_at=_today_at(), entry_reason="", order_id="", remaining_pct=1.0, + ) + + +@pytest.mark.asyncio +async def test_intraday_restore_allows_closed_today_only_and_blocks_dt_exited(): + st = DayTradingState() + st.begin_restore() + st.closed_today |= {"I00001", "I00002"} + st.dt_exited_today.add("I00002") + with patch.object(xs, "day_trading_state", st), patch.object(xs, "warn_throttled") as w: + n = await xs._restore_lost_today_positions_impl( + [_row("I00001", 3), _row("I00002", 3)], {"I00001": {"hldg": 3}, "I00002": {"hldg": 3}}, {}, + ) + assert n == 1 and "I00001" in st.positions and "I00002" not in st.positions + assert any("I00002" in str(c.args) for c in w.call_args_list) # 조용한 continue 아님 + + +@pytest.mark.asyncio +async def test_detect_orphans_restore_candidates_use_exit_record_not_closed_today(): + st = DayTradingState() + st.begin_restore() + st.closed_today |= {"D00001", "D00002"} + st.dt_exited_today.add("D00002") + with patch.object(xs, "day_trading_state", st), \ + patch.object(settings, "DAY_TRADE_ORPHAN_GRACE_SEC", 0): + _orph, restores = await xs._detect_dt_orphans_impl( + _Session([]), [_row("D00001", 2), _row("D00002", 2)], {}, pm._today_kst(), + ) + assert [r.stock_code for r in restores] == ["D00001"] diff --git a/backend/tests/test_dt_restore_verify_r3_20261009.py b/backend/tests/test_dt_restore_verify_r3_20261009.py new file mode 100644 index 0000000..21297d9 --- /dev/null +++ b/backend/tests/test_dt_restore_verify_r3_20261009.py @@ -0,0 +1,271 @@ +"""[2026-10-09 하드닝 R3] DT 기동 복원 검증 + 원장 재구성 연패의 왕복 기준 회귀 테스트. + +결함 1 (MEDIUM): restore_from_db 가 closed_today·KIS 실보유를 보지 않고 당일 active day_trade 행을 + 인메모리로 되살려, 청산 큐 유실 뒤 재기동하면 이미 판 물량을 일반 청산 경로가 다시 팔 수 있었다. + → verify_restored_today_positions_v2 (장중 복원 경로 _restore_lost_today_positions_impl 과 같은 규칙). +결함 2 (LOW): Redis 미스 시 원장 재구성의 연패가 마지막 레그 기준이라 실시간 왕복 기준(R2-4)과 달랐다. +""" +import inspect +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.daytrading import exit_service as xs +from app.services.daytrading import position_manager as pm +from app.services.daytrading.position_manager import DayTradingState + + +class _MustNotCall(BaseException): + """호출되면 안 되는 경로 — 프로덕션 except Exception 에 삼켜지지 않게 BaseException.""" + + +class _KISDown(Exception): + pass + + +class _Result: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return self + + def all(self): + return list(self._rows) + + +class _Session: + def __init__(self, rows): + self._rows = rows + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *_a, **_k): + return _Result(self._rows) + + +def _factory(rows): + return lambda: _Session(rows) + + +def _no_db(): + raise _MustNotCall("DB 조회가 일어나면 안 된다") + + +async def _no_kis(): + raise _MustNotCall("KIS 잔고 조회가 일어나면 안 된다") + + +def _holdings(mapping): + async def _f(): + return {c: {"hldg": q, "sellable": q} for c, q in mapping.items()} + return _f + + +async def _kis_down(): + raise _KISDown("balance down") + + +def _today_at(h=9, m=10): + return datetime.combine(pm._today_kst(), datetime.min.time()).replace(hour=h, minute=m) + + +def _pos(qty, entry_time=None, restored=True): + return { + "stock_name": "T", "strategy": "day_trade", "entry_price": 10_000.0, + "stop_loss": 9_800.0, "target_price": 10_300.0, "quantity": qty, + "highest_price": 10_000.0, "entry_time": entry_time or _today_at(), + "reason": "", "order_id": "", **({"restored_from_db": True} if restored else {}), + } + + +@pytest.fixture(autouse=True) +def _notify(): + m = AsyncMock(return_value=None) + with patch.object(pm, "_notify_daily_state_impl", m): + yield m + + +def _state(**positions): + st = DayTradingState() + st.begin_restore() + st.positions.update(positions) + return st + + +# ── 결함 1: 기동 복원 검증 ──────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_closed_today_restored_position_is_held_back_and_alerted(_notify): + """전량 청산 후 close 큐 유실 → active 행이 남아 복원돼도 DT 전량 청산 기록이면 되살리지 않는다. + + [R2 하드닝] 근거는 closed_today(재진입 차단)가 아니라 remove_position 이 남기는 dt_exited_today. + """ + st = _state(A00001=_pos(7)) + st.closed_today.add("A00001") + st.dt_exited_today.add("A00001") + r = await st.verify_restored_today_positions_v2(session_factory=_no_db, holdings_fetcher=_no_kis) + assert "A00001" not in st.positions + assert r["held"] == ["A00001"] + assert "A00001" in st.closed_today # 재진입 차단 유지 + _notify.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_kis_holding_minus_swing_below_row_qty_is_held_back(_notify): + """실보유 7주 중 스윙 4주 → DT 가용 3주 < 행 7주: 복원하면 스윙 물량을 판다(§3.5).""" + st = _state(A00002=_pos(7)) + swing = [SimpleNamespace(stock_code="A00002", quantity=4)] + r = await st.verify_restored_today_positions_v2( + session_factory=_factory(swing), holdings_fetcher=_holdings({"A00002": 7}), + ) + assert "A00002" not in st.positions + assert r["held"] == ["A00002"] and r["unverified"] is False + _notify.assert_awaited_once() + assert "A00002" not in st.closed_today # 잔고가 맞으면 장중 복원이 다시 잡을 수 있게 + + +@pytest.mark.asyncio +async def test_partial_sell_upsert_lost_row_qty_exceeds_holding_is_held_back(): + """분할 매도 후 잔량 upsert 유실 — 행 10주, 실보유 5주 → 보류.""" + st = _state(A00003=_pos(10)) + r = await st.verify_restored_today_positions_v2( + session_factory=_factory([]), holdings_fetcher=_holdings({"A00003": 5}), + ) + assert r["held"] == ["A00003"] and "A00003" not in st.positions + + +@pytest.mark.asyncio +async def test_consistent_position_is_kept(_notify): + st = _state(A00004=_pos(7)) + p = st.positions["A00004"] + swing = [SimpleNamespace(stock_code="A00004", quantity=3)] + r = await st.verify_restored_today_positions_v2( + session_factory=_factory(swing), holdings_fetcher=_holdings({"A00004": 10}), + ) + assert st.positions["A00004"] is p + assert r["held"] == [] and r["checked"] == 1 + _notify.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_prior_day_and_non_restored_positions_are_not_touched(): + """전일 진입분은 close_stale_positions 담당, 복원 표식 없는 포지션은 대상 아님.""" + yday = _today_at() - timedelta(days=1) + st = _state(S00001=_pos(5, entry_time=yday), N00001=_pos(5, restored=False)) + st.closed_today |= {"S00001", "N00001"} + r = await st.verify_restored_today_positions_v2(session_factory=_no_db, holdings_fetcher=_no_kis) + assert set(st.positions) == {"S00001", "N00001"} + assert r["checked"] == 0 + + +@pytest.mark.asyncio +async def test_unverified_holds_back_when_orphan_detect_enabled(_notify): + """판정 불가(KIS 장애) → 장중 close_stale_positions 가 같은 규칙으로 재복원하므로 보류(경보는 warning).""" + st = _state(A00005=_pos(3)) + with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True): + r = await st.verify_restored_today_positions_v2( + session_factory=_factory([]), holdings_fetcher=_kis_down, + ) + assert r["unverified"] is True and r["held"] == ["A00005"] + assert "A00005" not in st.positions + _notify.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_unverified_keeps_when_orphan_detect_disabled(): + """재복원 경로가 없으면 보류는 곧 손절 공백 — 유지하고 error.""" + st = _state(A00006=_pos(3)) + with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", False): + r = await st.verify_restored_today_positions_v2( + session_factory=_factory([]), holdings_fetcher=_kis_down, + ) + assert r["unverified"] is True and r["held"] == [] + assert "A00006" in st.positions + + +@pytest.mark.asyncio +async def test_db_failure_is_unverified_and_skips_kis(): + def _db_down(): + raise _KISDown("db down") + st = _state(A00007=_pos(3)) + with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True): + r = await st.verify_restored_today_positions_v2(session_factory=_db_down, holdings_fetcher=_no_kis) + assert r["unverified"] is True and "A00007" not in st.positions + + +@pytest.mark.asyncio +async def test_default_fetcher_is_exit_service_holdings_common_terminal(): + """기본 잔고 조회는 exit_service._fetch_kis_holdings_v2_impl (장중 복원 경로와 같은 소스).""" + st = _state(A00008=_pos(3)) + m = AsyncMock(side_effect=_KISDown("blocked")) + with patch.object(xs, "_fetch_kis_holdings_v2_impl", m), \ + patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True): + r = await st.verify_restored_today_positions_v2(session_factory=_factory([])) + m.assert_awaited_once() + assert r["unverified"] is True + + +def test_restore_from_db_signature_unchanged_and_scheduler_wires_verify(): + assert list(inspect.signature(DayTradingState.restore_from_db).parameters) == ["self", "db"] + from app.services.daytrading import scheduler as sch + src = inspect.getsource(sch) + i_restore = src.index("restore_from_db(db)") + i_verify = src.index("verify_restored_today_positions_v2()") + i_stale = src.index('close_stale_positions(), "기동 시') + assert i_restore < i_verify < i_stale + + +# ── 결함 2: 원장 재구성 연패 = 왕복 순손익 ───────────────────────────────────── + +def _row(i, code, order_type, net=None): + return SimpleNamespace( + id=i, stock_code=code, order_type=order_type, status="COMPLETED", exit_reason="", + net_profit_loss=net, strategy_type="daytrading", created_at=_today_at(9, 10 + i), + ) + + +def test_partial_profit_then_small_loss_is_round_trip_win(): + """+1.5R 분할 +3,000 뒤 잔량 -300 → 왕복 +2,700 = 이익 → 연패 리셋 (실시간 R2-4 와 동일).""" + rows = [ + _row(0, "L00001", "BUY"), _row(1, "L00001", "SELL", -500.0), + _row(2, "L00002", "BUY"), _row(3, "L00002", "SELL", -500.0), + _row(4, "P00001", "BUY"), _row(5, "P00001", "SELL", 3000.0), _row(6, "P00001", "SELL", -300.0), + ] + r = pm._rebuild_daily_state_from_rows(rows) + assert r["loss_streak_today"] == 0 + assert r["daily_pnl"] == pytest.approx(-500.0 - 500.0 + 3000.0 - 300.0) + + +def test_partial_small_profit_then_big_loss_is_round_trip_loss(): + rows = [_row(0, "P00002", "BUY"), _row(1, "P00002", "SELL", 100.0), _row(2, "P00002", "SELL", -300.0)] + assert pm._rebuild_daily_state_from_rows(rows)["loss_streak_today"] == 1 + + +def test_rebuild_streak_matches_realtime_round_trip_rule(): + """재구성 결과 = 실시간 _apply_round_trip_streak_v2 를 같은 왕복 순서로 적용한 결과.""" + trips = [("A", [-10.0]), ("B", [500.0, -20.0]), ("C", [-5.0]), ("D", [50.0, -80.0]), ("E", [0.0])] + rows, i = [], 0 + for code, legs in trips: + rows.append(_row(i, code, "BUY")); i += 1 + for leg in legs: + rows.append(_row(i, code, "SELL", leg)); i += 1 + rebuilt = pm._rebuild_daily_state_from_rows(rows)["loss_streak_today"] + + st = xs.day_trading_state + saved = st.loss_streak_today + try: + st.loss_streak_today = 0 + for _code, legs in trips: + xs._apply_round_trip_streak_v2(sum(legs)) + realtime = st.loss_streak_today + finally: + st.loss_streak_today = saved + assert rebuilt == realtime == 2 diff --git a/backend/tests/test_dt_safety_fixes_20261002.py b/backend/tests/test_dt_safety_fixes_20261002.py index 170328c..0a4fb5b 100644 --- a/backend/tests/test_dt_safety_fixes_20261002.py +++ b/backend/tests/test_dt_safety_fixes_20261002.py @@ -137,6 +137,8 @@ def entry_env(monkeypatch): monkeypatch.setattr(pcb, "reflect_buy", AsyncMock()) monkeypatch.setattr(mcp_mod, "request_mcp_risk_assessment", AsyncMock(return_value=(True, "")), raising=False) + # [R1-2 2026-10-09] 주문 직전 신선가 조회 — 이 픽스처의 테스트는 분봉가 경로(조회 실패 폴백)를 본다 + monkeypatch.setattr(ens, "_fetch_fresh_price_v2_impl", AsyncMock(return_value=None)) return captured @@ -483,7 +485,7 @@ async def test_m2_positions_endpoint_serializes_inf(): pos = _pos() pos["target_price"] = float("inf") day_trading_state.positions["555555"] = pos - with patch.object(dt, "_get_current_price", AsyncMock(return_value=10_100.0)): + with patch.object(dt, "_get_current_price_v2", AsyncMock(return_value=10_100.0)): res = await dt.get_positions() JSONResponse(content=res) # inf 가 남아 있으면 ValueError(=500) assert res["positions"][0]["target_price"] is None @@ -497,7 +499,7 @@ def test_m4_orb_branch_runs_stop_check_before_collection(): from app.services.daytrading import scheduler as sched src = inspect.getsource(sched.daytrading_scheduler) orb = src[src.index("if is_opening_range_period():"):] - orb = orb[:orb.index("await asyncio.sleep(30)")] + orb = orb[:orb.index("await _cycle_sleep(30)")] assert "check_stops_only_v2()" in orb assert orb.index("check_stops_only_v2()") < orb.index("collect_opening_ranges()") @@ -541,9 +543,13 @@ async def test_m5_manual_cycle_rejects_while_scheduler_holds_lock(): async def test_m5_manual_cycle_gates_and_holds_lock(): from fastapi import HTTPException from app.api.endpoints import day_trading as dt - with patch.object(settings, "DAY_TRADE_ENABLED", False): - with pytest.raises(HTTPException): + # 장 운영 시간 밖이면 409 (청산·진입 모두 호출하지 않음 — 예외형 목) + with patch.object(dt, "is_market_open", return_value=False), \ + patch.object(es, "check_and_exit_positions", _boom("check_and_exit_positions")), \ + patch.object(ens, "scan_and_enter", _boom("scan_and_enter")): + with pytest.raises(HTTPException) as ei: await dt.manual_cycle() + assert ei.value.status_code == 409 seen = [] @@ -565,6 +571,56 @@ async def test_m5_manual_cycle_gates_and_holds_lock(): assert res["entry_skipped"] +@pytest.mark.asyncio +async def test_r4_manual_cycle_runs_exit_when_dt_disabled(): + """[R4 2026-10-09] DAY_TRADE_ENABLED=False 여도 청산 체크는 수행(스케줄러 규약과 일치). + + 진입 시간창 안이어도 scan_and_enter 는 호출되면 안 된다 — 예외형 목으로 고정. + """ + from app.api.endpoints import day_trading as dt + seen = [] + + async def _exit(): + seen.append(("exit", dt_cycle_lock.locked())) + return [{"stock_code": "111111", "reason": "손절"}] + + with patch.object(dt, "is_market_open", return_value=True), \ + patch.object(dt, "is_day_trade_entry_window", return_value=True), \ + patch.object(settings, "DAY_TRADE_ENABLED", False), \ + patch.object(es, "check_and_exit_positions", _exit), \ + patch.object(ens, "scan_and_enter", _boom("scan_and_enter")): + res = await dt.manual_cycle() + assert seen == [("exit", True)], "비활성이어도 청산은 락 안에서 1회 수행" + assert res["exits"] == [{"stock_code": "111111", "reason": "손절"}] + assert res["entries"] == [] + assert res["entry_skipped"] == "DAY_TRADE_ENABLED=False" + + +@pytest.mark.asyncio +async def test_r4_manual_cycle_enabled_in_window_scans(): + """활성 + 진입창 안이면 청산 후 진입 스캔(기존 동작 유지).""" + from app.api.endpoints import day_trading as dt + order = [] + + async def _exit(): + order.append("exit") + return [] + + async def _scan(): + order.append("scan") + return [{"stock_code": "222222"}] + + with patch.object(dt, "is_market_open", return_value=True), \ + patch.object(dt, "is_day_trade_entry_window", return_value=True), \ + patch.object(settings, "DAY_TRADE_ENABLED", True), \ + patch.object(es, "check_and_exit_positions", _exit), \ + patch.object(ens, "scan_and_enter", _scan): + res = await dt.manual_cycle() + assert order == ["exit", "scan"] + assert res["entry_skipped"] is None + assert res["entries"] == [{"stock_code": "222222"}] + + # ═════════════════════════════════════════════════════════════════════════════ # M6 / M8 — scan_and_enter 게이트 # ═════════════════════════════════════════════════════════════════════════════ @@ -1421,7 +1477,7 @@ async def test_r5_lookup_failures_hold_and_alert_telegram(monkeypatch): sent = await _r5_setup(monkeypatch, AsyncMock(side_effect=RuntimeError("KIS down"))) alert = AsyncMock() from app.services import telegram_notifier as tn - monkeypatch.setattr(tn.telegram_notifier, "notify_system_warning", alert) + monkeypatch.setattr(tn.TelegramNotifier, "notify_system_warning", alert) monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT", 3) day_trading_state.positions["333333"] = p = _pos(10) await es._execute_exit("333333", p, 9_600.0, "손절") @@ -1477,3 +1533,63 @@ async def test_r5_stale_row_without_holdings_drop_is_not_acceptance(monkeypatch) with patch.object(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): await _run_stale(2, _stale_env(_order, bal=_bal_seq("064400", (4, 4)))) assert len(calls) == 2, "잔고 감소 없는 주문내역 행을 접수로 오인해 재매도를 막았다" + + +# ═════════════════════════════════════════════════════════════════════════════ +# R7 — /positions 현재가 조회 실패 시 -100% 표시 금지 +# ═════════════════════════════════════════════════════════════════════════════ + +def _boom_price(*a, **k): + raise RuntimeError("KIS 현재가 장애(테스트)") + + +@pytest.mark.asyncio +async def test_r7_positions_price_failure_uses_engine_cached_price(monkeypatch, caplog): + from app.services.korea_investment_api import KoreaInvestmentAPI + from app.api.endpoints import day_trading as dt + monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price", AsyncMock(side_effect=_boom_price)) + dt._price_fail_logged_at.clear() + pos = _pos() + entry = float(pos["entry_price"]) + pos["current_price"] = entry * 1.01 + day_trading_state.positions["666666"] = pos + with caplog.at_level("WARNING"): + res = await dt.get_positions() + row = [r for r in res["positions"] if r["stock_code"] == "666666"][0] + assert row["price_source"] == "engine_cached" + assert row["current_price"] == pytest.approx(entry * 1.01) + assert row["pnl_pct"] == pytest.approx(1.0, abs=0.01) + assert any("현재가 조회 실패" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_r7_positions_price_failure_without_cache_returns_none(monkeypatch, caplog): + from fastapi.responses import JSONResponse + from app.services.korea_investment_api import KoreaInvestmentAPI + from app.api.endpoints import day_trading as dt + monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price", AsyncMock(side_effect=_boom_price)) + dt._price_fail_logged_at.clear() + pos = _pos() + pos.pop("current_price", None) + day_trading_state.positions["777777"] = pos + with caplog.at_level("WARNING"): + res = await dt.get_positions() + await dt.get_positions() # 두 번째 실패는 스로틀로 경고 생략 + JSONResponse(content=res) + row = [r for r in res["positions"] if r["stock_code"] == "777777"][0] + assert row["current_price"] is None + assert row["pnl"] is None and row["pnl_pct"] is None + assert row["price_source"] == "unavailable" + warns = [r for r in caplog.records if "777777" in r.message and "현재가 조회 실패" in r.message] + assert len(warns) == 1 + + +@pytest.mark.asyncio +async def test_r7_get_current_price_legacy_contract_and_zero_price(monkeypatch): + from app.services.korea_investment_api import KoreaInvestmentAPI + from app.api.endpoints import day_trading as dt + monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "0"}})) + dt._price_fail_logged_at.clear() + assert await dt._get_current_price_v2("888888") is None + assert await dt._get_current_price("888888") == 0.0 diff --git a/backend/tests/test_dt_scheduler_alert_tasks_r3.py b/backend/tests/test_dt_scheduler_alert_tasks_r3.py new file mode 100644 index 0000000..418272b --- /dev/null +++ b/backend/tests/test_dt_scheduler_alert_tasks_r3.py @@ -0,0 +1,98 @@ +"""[하드닝 R3] DT 청산 연속 실패 경보 태스크 — 강참조 보관·타임아웃·실패 로그. + +목은 공통 종단(telegram_notifier.notify_system_warning)에 건다. 실패 경로는 예외 발생형. +""" +import asyncio +import gc +import logging + +import pytest + +from app.services.daytrading import scheduler as dts +from app.services.telegram_notifier import TelegramNotifier + + +class _SendBoom(Exception): + pass + + +@pytest.fixture(autouse=True) +def _reset(): + dts._alert_tasks.clear() + dts._dt_exit_health["consecutive_failures"] = 0 + dts._dt_exit_health["last_error"] = None + yield + dts._alert_tasks.clear() + + +@pytest.mark.asyncio +async def test_alert_task_strongly_referenced_until_done(monkeypatch): + gate = asyncio.Event() + sent = [] + + async def _fake(self, category, message): + await gate.wait() + sent.append((category, message)) + + monkeypatch.setattr(TelegramNotifier, "notify_system_warning", _fake) + dts._notify_dt_warning_bg_v2("hello") + assert len(dts._alert_tasks) == 1 + gc.collect() + assert len(dts._alert_tasks) == 1 # GC 후에도 참조 유지 + gate.set() + for _ in range(5): + await asyncio.sleep(0) + assert sent == [("DAYTRADING", "hello")] + assert len(dts._alert_tasks) == 0 # 완료 후 제거 + + +@pytest.mark.asyncio +async def test_alert_via_exit_failure_threshold(monkeypatch): + sent = [] + + async def _fake(self, category, message): + sent.append(message) + + monkeypatch.setattr(TelegramNotifier, "notify_system_warning", _fake) + for _ in range(dts.DT_EXIT_FAILURE_ALERT_THRESHOLD): + dts._record_dt_exit_result_v2(False, "timeout") + assert len(dts._alert_tasks) == 1 + await asyncio.gather(*list(dts._alert_tasks)) + await asyncio.sleep(0) + assert len(sent) == 1 and "timeout" in sent[0] + assert not dts._alert_tasks + + +@pytest.mark.asyncio +async def test_alert_send_exception_logged_error(monkeypatch, caplog): + async def _boom(self, category, message): + raise _SendBoom("telegram down") + + monkeypatch.setattr(TelegramNotifier, "notify_system_warning", _boom) + with caplog.at_level(logging.ERROR, logger=dts.logger.name): + dts._notify_dt_warning_bg_v2("msg-x") + await asyncio.gather(*list(dts._alert_tasks)) + assert any("경보 텔레그램 실패" in r.message and "msg-x" in r.message + and r.levelno == logging.ERROR for r in caplog.records) + await asyncio.sleep(0) + assert not dts._alert_tasks + + +@pytest.mark.asyncio +async def test_alert_send_timeout_logged_error(monkeypatch, caplog): + async def _hang(self, category, message): + await asyncio.sleep(3600) + + monkeypatch.setattr(TelegramNotifier, "notify_system_warning", _hang) + monkeypatch.setattr(dts, "DT_ALERT_SEND_TIMEOUT_SEC", 0.05) + with caplog.at_level(logging.ERROR, logger=dts.logger.name): + dts._notify_dt_warning_bg_v2("msg-t") + await asyncio.wait_for(asyncio.gather(*list(dts._alert_tasks)), timeout=2.0) + assert any("초과" in r.message and "msg-t" in r.message for r in caplog.records) + + +def test_alert_without_running_loop_does_not_raise(caplog): + with caplog.at_level(logging.ERROR, logger=dts.logger.name): + dts._notify_dt_warning_bg_v2("no-loop") + assert any("경보 태스크 생성 실패" in r.message for r in caplog.records) + assert not dts._alert_tasks diff --git a/backend/tests/test_dt_scheduler_exit_priority_20261009.py b/backend/tests/test_dt_scheduler_exit_priority_20261009.py new file mode 100644 index 0000000..1d12baf --- /dev/null +++ b/backend/tests/test_dt_scheduler_exit_priority_20261009.py @@ -0,0 +1,459 @@ +"""데이트레이딩 스케줄러 — 청산 우선 회귀 테스트 (2026-10-09, 하드닝 루프 라운드 1). + +결함 1: DAY_TRADE_ENABLED=False 면 루프가 sleep+continue 로 EOD·전일/고아·손절 청산까지 + 건너뛰었다(§8.1-11 위반). → 비활성은 신규 진입(ORB 수집·watchlist·스캔)만 막는다. +결함 2: 진입 스캔(최대 150초)·ORB 수집 동안 보유 포지션의 손절 판정이 멈췄다. + → _run_with_stop_watch_impl 이 단계와 병행해 손절 선판정을 주기 실행한다. + +목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형, 매도 목은 공통 종단 +(_execute_exit_impl)에 건다. 기대값은 settings·모듈 상수에서 계산한다. +""" +import asyncio +import contextlib +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.daytrading import exit_service as es +from app.services.daytrading import scheduler as sched +from app.services.daytrading.position_manager import day_trading_state, dt_cycle_lock + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게).""" + + +class _StopLoop(BaseException): + """스케줄러 무한 루프 탈출용.""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +def _boom_coro_factory(name): + """scan_and_enter() 처럼 '호출 결과 코루틴'을 넘기는 자리 — 호출 즉시 예외.""" + def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +def _pos(entry=10_000.0, qty=10, stop=9_700.0, entry_time=None): + return { + "stock_name": "테스트", "strategy": "VWAP", "entry_price": entry, "quantity": qty, + "stop_loss": stop, "target_price": entry * 1.05, "highest_price": entry, + "entry_time": entry_time or es._kst_now(), "reason": "", "order_id": "0000999", + } + + +@pytest.fixture(autouse=True) +def _isolate_state(): + saved = dict(day_trading_state.positions) + saved_closed = set(day_trading_state.closed_today) + saved_assets = day_trading_state.last_total_assets + saved_pnl = day_trading_state.daily_pnl + saved_streak = day_trading_state.loss_streak_today + saved_orb = dict(day_trading_state.orb_cache) + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.daily_pnl = 0.0 + day_trading_state.loss_streak_today = 0 + day_trading_state.last_reset_date = es._kst_now().date() + day_trading_state.exits_in_progress.clear() + es._exit_tokens.clear() + sched._last_disabled_orphan_check = 0.0 + sched._orb_backfill_pending = False + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)): + yield + day_trading_state.positions.clear() + day_trading_state.positions.update(saved) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved_closed) + day_trading_state.last_total_assets = saved_assets + day_trading_state.daily_pnl = saved_pnl + day_trading_state.loss_streak_today = saved_streak + day_trading_state.orb_cache.clear() + day_trading_state.orb_cache.update(saved_orb) + day_trading_state.exits_in_progress.clear() + sched._last_disabled_orphan_check = 0.0 + sched._orb_backfill_pending = False + + +class _FakeSession: + async def __aenter__(self): + return object() + + async def __aexit__(self, *a): + return False + + +@contextlib.contextmanager +def _scheduler_env(*, enabled, eod=False, orb=False, entry_window=False, cycles=1, + stale_mock=None, **overrides): + """daytrading_scheduler 를 `cycles` 사이클만 돌리는 환경. 기본은 진입·청산 전부 금지(boom).""" + from app.core import database + + sleeps = {"n": 0} + + async def _sleep(sec): + sleeps["n"] += 1 + if sleeps["n"] > cycles: # 첫 호출은 기동 대기(5초) + raise _StopLoop() + + mocks = { + "force_close_all": _boom("force_close_all"), + "check_and_exit_positions": _boom("check_and_exit_positions"), + "check_stops_only_v2": _boom("check_stops_only_v2"), + "scan_and_enter": _boom_coro_factory("scan_and_enter"), + "collect_opening_ranges": _boom_coro_factory("collect_opening_ranges"), + "populate_daytrading_watchlist": _boom("populate_daytrading_watchlist"), + } + mocks.update(overrides) + with contextlib.ExitStack() as st: + st.enter_context(patch.object(sched, "_cycle_sleep", _sleep)) + st.enter_context(patch.object(settings, "DAY_TRADE_ENABLED", enabled)) + st.enter_context(patch.object(sched, "is_market_open", lambda: True)) + st.enter_context(patch.object(sched, "is_eod_flat_time", lambda: eod)) + st.enter_context(patch.object(sched, "is_opening_range_period", lambda: orb)) + st.enter_context(patch.object(sched, "is_day_trade_entry_window", lambda: entry_window)) + st.enter_context(patch.object(sched, "get_watchlist", lambda: {"005930": "삼성전자"})) + st.enter_context(patch.object(sched, "is_watchlist_auto", lambda: False)) + st.enter_context(patch.object(day_trading_state, "begin_restore", lambda: True)) + st.enter_context(patch.object(day_trading_state, "load_daily_state", AsyncMock(return_value=True))) + st.enter_context(patch.object(day_trading_state, "load_orb_cache", AsyncMock(return_value=0))) + st.enter_context(patch.object(day_trading_state, "restore_from_db", AsyncMock(return_value=0))) + st.enter_context(patch.object(database, "AsyncSessionLocal", lambda: _FakeSession())) + st.enter_context(patch.object( + es, "close_stale_positions", stale_mock or AsyncMock(return_value=[]))) + if enabled: + st.enter_context(patch.object( + sched.mdd_manager, "is_short_term_strategy_enabled", AsyncMock(return_value=True))) + else: + # 비활성이면 진입 판단용 MDD 조회도 하지 않는다 + st.enter_context(patch.object( + sched.mdd_manager, "is_short_term_strategy_enabled", + _boom("is_short_term_strategy_enabled"))) + for name, m in mocks.items(): + st.enter_context(patch.object(sched, name, m)) + yield sleeps + + +async def _run_scheduler(): + with pytest.raises(_StopLoop): + await asyncio.wait_for(sched.daytrading_scheduler(), timeout=5.0) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 결함 1 — DAY_TRADE_ENABLED=False 여도 청산은 계속 +# ═════════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_disabled_still_runs_eod_force_close(): + day_trading_state.positions["111111"] = _pos() + fc = AsyncMock(return_value=[{"stock_code": "111111"}]) + with _scheduler_env(enabled=False, eod=True, force_close_all=fc): + await _run_scheduler() + assert fc.await_count == 1 + + +@pytest.mark.asyncio +async def test_disabled_eod_without_positions_checks_orphans(): + stale = AsyncMock(return_value=[]) + with _scheduler_env(enabled=False, eod=True, stale_mock=stale): + await _run_scheduler() + # 기동 시 1회 + EOD 사이클 1회 + assert stale.await_count == 2 + + +@pytest.mark.asyncio +async def test_disabled_entry_window_runs_exit_check_but_no_entry(): + day_trading_state.positions["111111"] = _pos() + ex = AsyncMock(return_value=[]) + with _scheduler_env(enabled=False, entry_window=True, check_and_exit_positions=ex): + await _run_scheduler() # scan/populate/MDD 는 boom + assert ex.await_count == 1 + + +@pytest.mark.asyncio +async def test_disabled_orb_period_runs_stop_check_but_no_collection(): + day_trading_state.positions["111111"] = _pos() + stops = AsyncMock(return_value=[]) + with _scheduler_env(enabled=False, orb=True, check_stops_only_v2=stops): + await _run_scheduler() # collect_opening_ranges/populate 는 boom + assert stops.await_count == 1 + + +@pytest.mark.asyncio +async def test_disabled_without_positions_throttles_orphan_check(): + """보유 0 + 비활성이면 고아 점검은 DISABLED_ORPHAN_CHECK_INTERVAL_SEC 주기로만.""" + assert sched.DISABLED_ORPHAN_CHECK_INTERVAL_SEC > 60 + ex = AsyncMock(return_value=[]) + with _scheduler_env(enabled=False, cycles=3, check_and_exit_positions=ex): + await _run_scheduler() + assert ex.await_count == 1 # 첫 사이클만, 이후 2사이클은 간격 안이라 생략 + + +@pytest.mark.asyncio +async def test_disabled_with_positions_checks_every_cycle(): + day_trading_state.positions["111111"] = _pos() + ex = AsyncMock(return_value=[]) + with _scheduler_env(enabled=False, cycles=3, check_and_exit_positions=ex): + await _run_scheduler() + assert ex.await_count == 3 + + +@pytest.mark.asyncio +async def test_enabled_entry_window_scans_under_lock_with_stop_watch(): + """활성 경로 회귀 — 스캔은 dt_cycle_lock 아래, 손절 감시 래퍼(lock_held=True)로 실행.""" + seen = {} + + async def _scan(): + seen["scan_locked"] = dt_cycle_lock.locked() + return [] + + async def _wrapper(coro, step, timeout, *, lock_held, abort_on_entry_block=False): + seen["args"] = (step, timeout, lock_held, abort_on_entry_block) + return await coro + + ex = AsyncMock(return_value=[]) + with _scheduler_env(enabled=True, entry_window=True, check_and_exit_positions=ex, + scan_and_enter=_scan), \ + patch.object(sched, "_run_with_stop_watch_impl", _wrapper): + await _run_scheduler() + assert ex.await_count == 1 + assert seen["scan_locked"] is True + assert seen["args"] == ("진입 스캔", sched.STEP_TIMEOUT, True, True) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 결함 2 — 진입 스캔 중 손절 감시 +# ═════════════════════════════════════════════════════════════════════════════ + +@pytest.fixture +def fast_watch(monkeypatch): + monkeypatch.setattr(sched, "SCAN_STOP_WATCH_INTERVAL_SEC", 0.03) + monkeypatch.setattr(sched, "_SCAN_STOP_WATCH_MIN_SEC", 0.01) + monkeypatch.setattr(settings, "DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC", 1.0) + return 0.03 + + +def test_stop_watch_interval_defaults_and_floor(monkeypatch): + assert sched._stop_watch_interval() == max( + sched._SCAN_STOP_WATCH_MIN_SEC, sched.SCAN_STOP_WATCH_INTERVAL_SEC) + # 비정상 설정값은 기본값으로, 너무 작은 값은 하한으로 (config 에 키가 없어 대체 객체로 주입) + from types import SimpleNamespace + monkeypatch.setattr(sched, "settings", SimpleNamespace(DAY_TRADE_SCAN_STOP_WATCH_INTERVAL_SEC=-1)) + assert sched._stop_watch_interval() == max( + sched._SCAN_STOP_WATCH_MIN_SEC, sched.SCAN_STOP_WATCH_INTERVAL_SEC) + monkeypatch.setattr(sched, "settings", SimpleNamespace(DAY_TRADE_SCAN_STOP_WATCH_INTERVAL_SEC=0.001)) + assert sched._stop_watch_interval() == sched._SCAN_STOP_WATCH_MIN_SEC + monkeypatch.setattr(sched, "settings", SimpleNamespace(DAY_TRADE_SCAN_STOP_WATCH_INTERVAL_SEC="x")) + assert sched._stop_watch_interval() == max( + sched._SCAN_STOP_WATCH_MIN_SEC, sched.SCAN_STOP_WATCH_INTERVAL_SEC) + # 스캔 _guard(STEP_TIMEOUT) 보다 충분히 짧아야 의미가 있다 + assert sched.SCAN_STOP_WATCH_INTERVAL_SEC < sched.STEP_TIMEOUT / 4 + + +@pytest.mark.asyncio +async def test_stop_loss_fires_during_long_scan(fast_watch): + day_trading_state.positions["111111"] = _pos(stop=9_700.0) + day_trading_state.positions["222222"] = _pos(stop=9_700.0) + prices = {"111111": "9600", "222222": "10500"} + state = {"scan_done": False} + sold = [] + + async def _exec(code, pos, price, reason, sell_pct, is_stale): + sold.append((code, state["scan_done"], is_stale)) + day_trading_state.positions.pop(code, None) + return {"stock_code": code} + + async def _slow_scan(): + await asyncio.sleep(fast_watch * 8) + state["scan_done"] = True + return ["entry"] + + with patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(side_effect=lambda c: {"output": {"stck_prpr": prices[c]}})), \ + patch.object(es.korea_investment_api, "get_minute_chart_full_day", + _boom("get_minute_chart_full_day")), \ + patch.object(es, "_execute_exit_impl", _exec): + async with dt_cycle_lock: # 스케줄러처럼 락을 쥔 채 — 감시가 교착되면 안 된다 + out = await asyncio.wait_for( + sched._run_with_stop_watch_impl( + _slow_scan(), "진입 스캔", 5, lock_held=True, abort_on_entry_block=True), + timeout=3.0, + ) + assert out == ["entry"] + assert sold == [("111111", False, False)], "스캔이 끝나기 전에 손절 이탈 종목만 매도" + assert "222222" in day_trading_state.positions + + +@pytest.mark.asyncio +async def test_scan_aborted_when_watch_exit_triggers_cooldown(fast_watch): + day_trading_state.positions["111111"] = _pos(stop=9_700.0) + entered = [] + + async def _exec(code, pos, price, reason, sell_pct, is_stale): + day_trading_state.positions.pop(code, None) + day_trading_state.loss_streak_today = 3 # 이 청산으로 3연패 + return {"stock_code": code} + + async def _slow_scan(): + await asyncio.sleep(fast_watch * 20) + entered.append("late-entry") + return ["late-entry"] + + with patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "9600"}})), \ + patch.object(es, "_execute_exit_impl", _exec): + out = await asyncio.wait_for( + sched._run_with_stop_watch_impl( + _slow_scan(), "진입 스캔", 5, lock_held=True, abort_on_entry_block=True), + timeout=3.0, + ) + assert out is None + assert entered == [], "쿨다운이 걸린 뒤의 진입은 일어나면 안 된다" + + +@pytest.mark.asyncio +async def test_scan_aborted_when_watch_exit_breaches_daily_loss(fast_watch, monkeypatch): + day_trading_state.positions["111111"] = _pos(stop=9_700.0) + day_trading_state.last_total_assets = 10_000_000.0 + limit = sched.is_daily_loss_exceeded # 실제 판정 함수(설정값 기반)를 그대로 쓴다 + entered = [] + + async def _exec(code, pos, price, reason, sell_pct, is_stale): + day_trading_state.positions.pop(code, None) + day_trading_state.daily_pnl = -day_trading_state.last_total_assets # -100% 실현 + return {"stock_code": code} + + async def _slow_scan(): + await asyncio.sleep(fast_watch * 20) + entered.append("late-entry") + return [] + + with patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "9600"}})), \ + patch.object(es, "_execute_exit_impl", _exec): + out = await asyncio.wait_for( + sched._run_with_stop_watch_impl( + _slow_scan(), "진입 스캔", 5, lock_held=True, abort_on_entry_block=True), + timeout=3.0, + ) + assert limit(day_trading_state.last_total_assets, day_trading_state.daily_pnl) + assert out is None and entered == [] + + +@pytest.mark.asyncio +async def test_orb_collection_not_aborted_by_watch(fast_watch): + """abort_on_entry_block=False(ORB 수집)는 감시 청산이 있어도 단계를 끝까지 수행.""" + day_trading_state.positions["111111"] = _pos(stop=9_700.0) + + async def _exec(code, pos, price, reason, sell_pct, is_stale): + day_trading_state.positions.pop(code, None) + day_trading_state.loss_streak_today = 3 + return {"stock_code": code} + + async def _collect(): + await asyncio.sleep(fast_watch * 5) + return "collected" + + with patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "9600"}})), \ + patch.object(es, "_execute_exit_impl", _exec): + out = await asyncio.wait_for( + sched._run_with_stop_watch_impl(_collect(), "ORB 수집", 5, lock_held=False), + timeout=3.0, + ) + assert out == "collected" + assert "111111" not in day_trading_state.positions + + +@pytest.mark.asyncio +async def test_unlocked_watch_skips_when_other_cycle_holds_lock(fast_watch): + """락 밖 단계(ORB 수집)의 감시는 /cycle 등 다른 사이클이 락을 쥐면 그 라운드를 건너뛴다.""" + day_trading_state.positions["111111"] = _pos(stop=9_700.0) + + async def _collect(): + await asyncio.sleep(fast_watch * 5) + return "ok" + + with patch.object(es.korea_investment_api, "get_current_price", + _boom("get_current_price")), \ + patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): + await dt_cycle_lock.acquire() + try: + out = await asyncio.wait_for( + sched._run_with_stop_watch_impl(_collect(), "ORB 수집", 5, lock_held=False), + timeout=3.0, + ) + finally: + dt_cycle_lock.release() + assert out == "ok" + + +@pytest.mark.asyncio +async def test_watch_ignores_stale_positions(fast_watch): + """전일 포지션은 count_daily_pnl=False 경로(close_stale_positions) 몫 — 감시가 당일 경로로 팔면 안 된다.""" + from datetime import timedelta + day_trading_state.positions["111111"] = _pos( + stop=9_700.0, entry_time=es._kst_now() - timedelta(days=1)) + with patch.object(es.korea_investment_api, "get_current_price", + _boom("get_current_price")), \ + patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): + assert await sched._stop_watch_round_impl(lock_held=True) == [] + + +@pytest.mark.asyncio +async def test_watch_error_does_not_break_scan(fast_watch): + day_trading_state.positions["111111"] = _pos(stop=9_700.0) + + async def _scan(): + await asyncio.sleep(fast_watch * 4) + return ["e"] + + with patch.object(sched, "_stop_watch_round_impl", + AsyncMock(side_effect=RuntimeError("시세 장애"))): + out = await asyncio.wait_for( + sched._run_with_stop_watch_impl(_scan(), "진입 스캔", 5, lock_held=True, + abort_on_entry_block=True), + timeout=3.0, + ) + assert out == ["e"] + + +@pytest.mark.asyncio +async def test_outer_cancellation_propagates(fast_watch): + """스케줄러 종료(바깥 취소)는 흡수하지 않고 전파 — 단계와 감시도 함께 정리.""" + scan_cancelled = asyncio.Event() + + async def _scan(): + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + scan_cancelled.set() + raise + + task = asyncio.ensure_future( + sched._run_with_stop_watch_impl(_scan(), "진입 스캔", 30, lock_held=True, + abort_on_entry_block=True)) + await asyncio.sleep(fast_watch * 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert scan_cancelled.is_set() + + +@pytest.mark.asyncio +async def test_guard_timeout_still_applies(fast_watch): + async def _hang(): + await asyncio.sleep(10) + + out = await asyncio.wait_for( + sched._run_with_stop_watch_impl(_hang(), "진입 스캔", 0.1, lock_held=True), + timeout=3.0, + ) + assert out is None diff --git a/backend/tests/test_dt_stale_pnl_daily_reset_r7_20261010.py b/backend/tests/test_dt_stale_pnl_daily_reset_r7_20261010.py new file mode 100644 index 0000000..8f41a03 --- /dev/null +++ b/backend/tests/test_dt_stale_pnl_daily_reset_r7_20261010.py @@ -0,0 +1,94 @@ +"""[R7 2026-10-10] 전일·고아 청산 손익 누적(_stale_realized_pnl)이 날짜가 바뀌면 0 으로 돌아가는지. + +종전: 모듈 전역 누적값을 초기화하는 코드가 없어, 재기동 없이 여러 날 돌면 warning 로그의 +'전일 누적'과 /daytrading/close-all 응답의 stale_realized_pnl 이 여러 날의 합이 됐다. + +주문 경로는 dt_env(공통 종단 safe_place_order 목 + DB 큐 목)를 재사용한다. +기대 순손익은 settings 비용률로 독립 계산한다. +""" +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, patch + +import pytest + +from app.services.daytrading import exit_service as es +from app.services.daytrading.position_manager import day_trading_state +from tests.test_dt_cost_fill_timestop_20261001 import ( # noqa: F401 (fixture) + dt_env, _expected_net, _pos, +) + + +@pytest.fixture +def stale_state(): + saved = (es._stale_realized_pnl, es._stale_realized_pnl_date) + yield + es._stale_realized_pnl, es._stale_realized_pnl_date = saved + + +def _clock(day: datetime): + box = {"now": day} + return box, (lambda: box["now"]) + + +def test_add_and_v2_reset_on_new_day(stale_state): + d1 = datetime(2026, 10, 8, 10, 0) + box, fn = _clock(d1) + with patch.object(es, "_kst_now", fn): + es._stale_realized_pnl, es._stale_realized_pnl_date = 0.0, None + es._add_stale_realized_pnl(-1_000.0) + es._add_stale_realized_pnl(300.0) + assert es.get_stale_realized_pnl_v2() == pytest.approx(-700.0) + + box["now"] = d1 + timedelta(days=1) + # 다음 날 가산 전에도 v2 는 0 (전일 값이 당일 응답에 새지 않는다) + assert es.get_stale_realized_pnl_v2() == 0.0 + es._add_stale_realized_pnl(-200.0) + assert es.get_stale_realized_pnl_v2() == pytest.approx(-200.0) + # 하위호환 함수도 같은 당일 값을 본다(가산 시점에 날짜 경계 처리) + assert es.get_stale_realized_pnl() == pytest.approx(-200.0) + + +def test_add_resets_even_without_v2_read(stale_state): + d1 = datetime(2026, 10, 8, 10, 0) + box, fn = _clock(d1) + with patch.object(es, "_kst_now", fn): + es._stale_realized_pnl, es._stale_realized_pnl_date = 0.0, None + es._add_stale_realized_pnl(-5_000.0) + box["now"] = d1 + timedelta(days=3) + assert es._add_stale_realized_pnl(100.0) == pytest.approx(100.0) + + +@pytest.mark.asyncio +async def test_stale_exit_across_days_reports_only_today(dt_env, stale_state): + """공통 종단(_execute_exit_stale → _finalize_exit_impl) 경로로 날짜를 넘겨 두 번 청산.""" + entry, exit_, qty = 10_000.0, 9_500.0, 10 + net, _ = _expected_net(entry, exit_, qty) + d1 = datetime(2026, 10, 8, 9, 30) + box, fn = _clock(d1) + with patch.object(es, "_kst_now", fn), \ + patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()): + es._stale_realized_pnl, es._stale_realized_pnl_date = 0.0, None + await es._execute_exit_stale("555555", _pos(entry, qty), exit_, "전일 미청산 포지션 강제 청산") + assert es.get_stale_realized_pnl_v2() == pytest.approx(net) + + box["now"] = d1 + timedelta(days=1) + await es._execute_exit_stale("666666", _pos(entry, qty, order_id="0000998"), exit_, + "전일 미청산 포지션 강제 청산") + assert es.get_stale_realized_pnl_v2() == pytest.approx(net) # 2*net 이 아니어야 한다 + # 당일 손익에는 여전히 넣지 않는다(§7) + assert day_trading_state.daily_pnl == 0.0 + + +def test_close_all_stale_response_uses_v2(stale_state): + """/close-all 빈 메모리 응답의 stale_realized_pnl 은 당일 값(v2)이어야 한다.""" + from app.api.endpoints import day_trading as dt_api + + d1 = datetime(2026, 10, 8, 10, 0) + box, fn = _clock(d1) + with patch.object(es, "_kst_now", fn): + es._stale_realized_pnl, es._stale_realized_pnl_date = 0.0, None + es._add_stale_realized_pnl(-9_000.0) + box["now"] = d1 + timedelta(days=1) + out = dt_api._stale_result_v2([]) + assert out["stale_realized_pnl"] == 0 diff --git a/backend/tests/test_dt_stale_unfilled_restore_r8_20261010.py b/backend/tests/test_dt_stale_unfilled_restore_r8_20261010.py new file mode 100644 index 0000000..25ed0b0 --- /dev/null +++ b/backend/tests/test_dt_stale_unfilled_restore_r8_20261010.py @@ -0,0 +1,298 @@ +"""DT 하드닝 라운드 8 — 전일·고아 매도 0체결/부분 종결 후 잔량 재청산 경로 (2026-10-10, daytrading/exit_service.py). + +R8-1 전일·고아 시장가 매도(count_daily_pnl=False)가 접수 후 0체결(또는 부분체결)로 종결되면 + _finalize_exit_impl 이 이미 ActiveTrade(day_trade) 를 닫고 reflect_sell 이 Portfolio 수량을 0 으로 + 깎아 둔다. 예전 _on_unfilled 는 재주문 차단 표식만 지워, close_stale_positions 의 전일 경로(active 행) + ·고아 경로(Portfolio 수량>0) 어느 쪽에도 다시 걸리지 않았고, 다음 sync 가 'daytrading' 태그를 풀어 + 스윙/external 로 넘겼다. 이제 미체결 수량으로 ActiveTrade(day_trade, active)를 되살린다: + - 전일·과거 고아: created_at = 원 진입 시각(오늘 이전) → 다음 사이클 전일 경로가 재매도 + - 당일 고아: created_at = 당일 진입 시각 → 당일 행 복원 경로가 인메모리 관리 재개 + +목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`)에, 호출되면 안 되는 경로는 +BaseException 발생형. 기대값은 settings·모듈 상수에서 계산한다. +""" +import asyncio +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.daytrading import exit_service as es +from app.services.daytrading.position_manager import day_trading_state + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +class _SeqSession: + def __init__(self, results): + self._results = list(results) + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *a, **k): + rows = self._results.pop(0) if self._results else [] + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) + + async def commit(self): + raise MustNotBeCalled("commit") + + +def _sess(*results): + return lambda: _SeqSession(results) + + +def _balance(rows): + return AsyncMock(return_value={ + "rt_cd": "0", + "output1": [{"pdno": c, "hldg_qty": str(h), "ord_psbl_qty": str(s)} for c, h, s in rows], + "output2": [{}], + }) + + +CODE = "005930" + + +def _today_at(h, m): + return datetime.combine(es._kst_now().date(), datetime.min.time()).replace(hour=h, minute=m) + + +@pytest.fixture(autouse=True) +def _clean(): + from app.services.telegram_notifier import telegram_notifier + saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today), + set(day_trading_state.dt_exited_today), set(day_trading_state.dt_reheld_today), + day_trading_state.daily_pnl, day_trading_state.loss_streak_today, es._stale_realized_pnl) + day_trading_state.reset_if_new_day() + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.dt_exited_today.clear() + day_trading_state.dt_reheld_today.clear() + day_trading_state.daily_pnl = 0.0 + day_trading_state.loss_streak_today = 0 + for coll in (es._stale_sell_attempted, es._stale_sell_failures, es._last_exit_order_outcome, + es._unresolved_exit_unknown, es._stale_cleaned, es._exit_tokens, + es._recent_dt_sell_finalized, es._manual_alerted, es._rest_price_seen): + coll.clear() + day_trading_state.exits_in_progress.clear() + tg = AsyncMock() + with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ + patch.object(telegram_notifier, "notify_system_warning", tg): + yield tg + day_trading_state.positions.clear() + day_trading_state.positions.update(saved[0]) + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved[1]) + day_trading_state.dt_exited_today.clear() + day_trading_state.dt_exited_today.update(saved[2]) + day_trading_state.dt_reheld_today.clear() + day_trading_state.dt_reheld_today.update(saved[3]) + day_trading_state.daily_pnl = saved[4] + day_trading_state.loss_streak_today = saved[5] + es._stale_realized_pnl = saved[6] + for coll in (es._unresolved_exit_unknown, es._manual_alerted, es._rest_price_seen, + es._recent_dt_sell_finalized, es._stale_sell_attempted, es._stale_cleaned, + es._stale_sell_failures, es._last_exit_order_outcome): + coll.clear() + + +def _stale_pos(qty=3, entry_time=None, orphan=False, orphan_today=False): + p = {"stock_name": "삼성전자", "strategy": "day_trade", "entry_price": 10_000.0, + "quantity": qty, "highest_price": 10_000.0, + "entry_time": entry_time or (_today_at(10, 0) - timedelta(days=2)), + "reason": "", "order_id": "0000123"} + if orphan: + p["orphan"] = True + p[es._ORPHAN_TODAY_KEY] = orphan_today + return p + + +async def _unfilled(pos, filled=0, avg=0.0, recorded_net=-3_000.0): + """stale 플래그(count_daily_pnl=False) 매도 → 체결 확인 콜백이 '종결·미체결'을 보고.""" + captured = {} + + def _sched(code, order_id, side, qty, cb): + captured["cb"] = cb + return object() + + upsert = AsyncMock() + with patch.object(es.fill_reconciler, "schedule_v2", _sched), \ + patch("app.services.db_worker.enqueue_active_trade_upsert", upsert), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()): + es._schedule_sell_fill_reconcile_v2( + stock_code=CODE, pos=pos, order_id="0000999", signal_price=9_000.0, + quantity=int(pos["quantity"]), entry_price=float(pos["entry_price"]), + recorded_net=recorded_net, count_daily_pnl=False, + ) + await captured["cb"]({"terminal": True, "filled_qty": filled, "avg_price": avg}) + return upsert + + +def _row_from_payload(p): + """db_worker 가 신규 생성할 ActiveTrade 행(필요 속성만).""" + return SimpleNamespace( + stock_code=p["stock_code"], stock_name=p.get("stock_name"), + strategy_name=p["strategy_name"], status=p["status"], + quantity=p["quantity"], entry_price=p["entry_price"], + stop_loss=p.get("stop_loss"), take_profit=p.get("take_profit"), + highest_since_entry=p.get("highest_since_entry"), + created_at=p.get("created_at"), entry_reason=p.get("entry_reason"), + order_id=p.get("order_id"), remaining_pct=p.get("remaining_pct"), + ) + + +async def _run_close_stale(session_factory, balance, exec_mock): + with patch.object(es, "is_market_open", return_value=True), \ + patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True), \ + patch("app.core.database.AsyncSessionLocal", session_factory), \ + patch.object(es.korea_investment_api, "get_balance", balance), \ + patch.object(es.korea_investment_api, "get_current_price", + AsyncMock(return_value={"output": {"stck_prpr": "9000"}})), \ + patch.object(es, "_execute_exit_impl", exec_mock): + out = await es.close_stale_positions() + await asyncio.sleep(0) + return out + + +# ═══ 전일 포지션 0체결 종결 → ActiveTrade 복원(원 진입일) ═══════════════════════ + +@pytest.mark.asyncio +async def test_stale_zero_fill_restores_active_trade_with_original_prior_date(): + pos = _stale_pos(qty=3) + _today = es._kst_now().date().isoformat() + es._stale_sell_attempted.add((_today, CODE)) + stale_before = es._stale_realized_pnl + upsert = await _unfilled(pos, filled=0, recorded_net=-3_000.0) + assert upsert.await_count == 1 + p = upsert.await_args.args[0] + assert p["strategy_name"] == "day_trade" and p["status"] == "active" + assert p["quantity"] == 3 + assert p["created_at"] == pos["entry_time"] + assert p["created_at"].date() < es._kst_now().date() # 전일 경로에 잡힌다 + assert p["remaining_pct"] == 1.0 + # 재주문 차단 표식 해제 + 전일 손익 되돌림(당일 손익 무변경, §7) + assert (_today, CODE) not in es._stale_sell_attempted + assert es._stale_realized_pnl == pytest.approx(stale_before + 3_000.0) + assert day_trading_state.daily_pnl == 0.0 + # 인메모리는 건드리지 않는다(전일 경로가 매도) + assert CODE not in day_trading_state.positions + + +@pytest.mark.asyncio +async def test_stale_partial_fill_restores_only_unfilled_quantity(): + pos = _stale_pos(qty=5) + upsert = await _unfilled(pos, filled=2, avg=9_000.0) + assert upsert.await_args.args[0]["quantity"] == 3 + + +@pytest.mark.asyncio +async def test_stale_zero_fill_then_next_cycle_resells_remaining(): + """회귀: 0체결 종결 → (복원된 active 행) → 다음 close_stale_positions 가 잔량을 재매도한다.""" + pos = _stale_pos(qty=3) + _today = es._kst_now().date().isoformat() + es._stale_sell_attempted.add((_today, CODE)) + es._stale_cleaned.add((_today, CODE)) + upsert = await _unfilled(pos, filled=0) + row = _row_from_payload(upsert.await_args.args[0]) + exec_mock = AsyncMock(return_value={"stock_code": CODE}) + # 쿼리: ①DT active [복원 행] ②비-DT [] ③Portfolio DT [] (고아 후보 없음) + await _run_close_stale(_sess([row], [], []), _balance([(CODE, 3, 3)]), exec_mock) + assert exec_mock.await_count == 1 + args, kwargs = exec_mock.await_args.args, exec_mock.await_args.kwargs + assert kwargs.get("is_stale") is True + assert args[1]["quantity"] == 3 + assert args[3].startswith("전일 미청산 포지션 강제 청산") + + +@pytest.mark.asyncio +async def test_stale_resell_still_deducts_swing_share(): + """§3.5 — 재매도도 실보유 − 비-DT active 수량으로 상한(복원 3주, 실보유 4, 스윙 2 → 2주).""" + pos = _stale_pos(qty=3) + upsert = await _unfilled(pos, filled=0) + row = _row_from_payload(upsert.await_args.args[0]) + swing = SimpleNamespace(stock_code=CODE, quantity=2) + exec_mock = AsyncMock(return_value={"stock_code": CODE}) + await _run_close_stale(_sess([row], [swing], []), _balance([(CODE, 4, 4)]), exec_mock) + assert exec_mock.await_args.args[1]["quantity"] == 2 + + +# ═══ 과거 고아 0체결 → 전일 경로로 복원 ═══════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_past_orphan_zero_fill_restores_prior_dated_row_without_orphan_flags(): + pos = _stale_pos(qty=3, orphan=True, orphan_today=False) + upsert = await _unfilled(pos, filled=0) + p = upsert.await_args.args[0] + assert p["quantity"] == 3 and p["created_at"].date() < es._kst_now().date() + assert "orphan" not in p and es._ORPHAN_TODAY_KEY not in p + + +def test_snapshot_without_valid_prior_entry_time_falls_back_before_today(): + """전일·과거 고아인데 진입 시각이 없거나 오늘이면 전일 15:30 으로 — 당일 복원 경로로 새지 않게.""" + for et in (None, _today_at(9, 30)): + snap = es._stale_unfilled_restore_snapshot_impl( + CODE, _stale_pos(qty=2) | {"entry_time": et}, 2, orphan_today=False, + ) + assert snap["entry_time"].date() < es._kst_now().date() + assert snap["entry_time"].time() == es._KRX_MARKET_CLOSE + + +def test_snapshot_none_when_no_unfilled(): + assert es._stale_unfilled_restore_snapshot_impl(CODE, _stale_pos(), 0, orphan_today=False) is None + + +# ═══ 당일 고아 0체결 → 당일 행 복원 경로(인메모리 관리 재개) ═══════════════════════ + +@pytest.mark.asyncio +async def test_today_orphan_zero_fill_then_restored_into_memory_with_stop_loss(): + buy_at = _today_at(9, 5) + pos = _stale_pos(qty=3, entry_time=buy_at, orphan=True, orphan_today=True) + day_trading_state.dt_exited_today.add(CODE) # _finalize_exit_impl 가 접수 때 남긴 전량 청산 기록 + upsert = await _unfilled(pos, filled=0) + p = upsert.await_args.args[0] + assert p["created_at"] == buy_at + assert day_trading_state.restore_block_reason_v2(CODE) is None + row = _row_from_payload(p) + # 매도는 일어나면 안 된다 — 당일 행은 복원 경로 + exec_mock = AsyncMock(side_effect=MustNotBeCalled("당일 고아 복원은 매도하지 않는다")) + # 쿼리: ①DT active [당일 행] ③Portfolio DT [] ⑤추가 비-DT [] + await _run_close_stale(_sess([row], [], []), _balance([(CODE, 3, 3)]), exec_mock) + restored = day_trading_state.positions.get(CODE) + assert restored is not None and restored["quantity"] == 3 + assert 0 < restored["stop_loss"] < restored["entry_price"] + + +@pytest.mark.asyncio +async def test_count_daily_pnl_branch_unchanged_no_stale_snapshot(): + """당일 포지션(count_daily_pnl=True) 0체결 복원은 기존 경로 그대로 — 새 헬퍼를 쓰지 않는다.""" + captured = {} + + def _sched(code, order_id, side, qty, cb): + captured["cb"] = cb + return object() + + pos = _stale_pos(qty=3, entry_time=_today_at(9, 5)) | {"stop_loss": 9_800.0, "target_price": 10_300.0} + with patch.object(es.fill_reconciler, "schedule_v2", _sched), \ + patch.object(es, "_stale_unfilled_restore_snapshot_impl", + lambda *a, **k: (_ for _ in ()).throw(MustNotBeCalled("stale snapshot"))), \ + patch("app.services.db_worker.enqueue_active_trade_upsert", AsyncMock()), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()): + es._schedule_sell_fill_reconcile_v2( + stock_code=CODE, pos=pos, order_id="0000998", signal_price=9_000.0, quantity=3, + entry_price=10_000.0, recorded_net=-3_000.0, count_daily_pnl=True, + ) + await captured["cb"]({"terminal": True, "filled_qty": 0, "avg_price": 0}) + assert day_trading_state.positions[CODE]["quantity"] == 3 diff --git a/backend/tests/test_dt_trailing_high_post_entry_20261001.py b/backend/tests/test_dt_trailing_high_post_entry_20261001.py index 2a005b4..c2ea2a4 100644 --- a/backend/tests/test_dt_trailing_high_post_entry_20261001.py +++ b/backend/tests/test_dt_trailing_high_post_entry_20261001.py @@ -136,8 +136,11 @@ async def _run_single(pos: dict, rows): return None # 청산 신호 없음 day_trading_state.positions["005930"] = pos + es._rsi_warmup_prev_cache.clear() with patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(return_value=rows)), \ + patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", + AsyncMock(return_value=[])), \ patch.object(es, "check_day_trade_exit", _capture): result = await es._check_single_position("005930") return result, captured diff --git a/backend/tests/test_dt_warmup_and_orb_volume_20260929.py b/backend/tests/test_dt_warmup_and_orb_volume_20260929.py index ee133af..7d7a09b 100644 --- a/backend/tests/test_dt_warmup_and_orb_volume_20260929.py +++ b/backend/tests/test_dt_warmup_and_orb_volume_20260929.py @@ -422,13 +422,20 @@ def test_exit_and_legacy_paths_stay_today_only(): from app.services.daytrading import entry_service as es from app.services.daytrading import exit_service as xs - exit_src = inspect.getsource(xs) legacy_src = inspect.getsource(legacy) - assert "get_minute_chart_full_day_v2" not in exit_src, ( - "DT 청산 경로에 전일 봉이 들어가면 트레일링·ATR·시간손절이 오염된다" - ) + # [R3-1 2026-10-09] 청산은 RSI 판정 시계열에 한해 전일 워밍업을 붙인다(진입과 같은 기준). + # 전일 포함 조회는 워밍업 헬퍼 한 곳에만 허용 — 현재가·고점(트레일링)·시간손절의 입력인 + # 당일 분봉 조회는 기존 메서드를 그대로 쓴다. ATR 은 _atr 이 당일 세션만 쓴다. + for name, fn in inspect.getmembers(xs, inspect.isfunction): + if fn.__module__ != xs.__name__ or name == "_rsi_warmup_prev_candles_impl": + continue + assert "get_minute_chart_full_day_v2" not in inspect.getsource(fn), ( + f"DT 청산 경로({name})에 전일 봉이 들어가면 트레일링·현재가·시간손절이 오염된다" + ) + assert "get_minute_chart_full_day_v2" in inspect.getsource(xs._rsi_warmup_prev_candles_impl) + single_src = inspect.getsource(xs._check_single_position) + assert "korea_investment_api.get_minute_chart_full_day(" in single_src assert "get_minute_chart_full_day_v2" not in legacy_src - assert "get_minute_chart_full_day" in exit_src orb_src = inspect.getsource(es.collect_opening_ranges) assert "get_minute_chart_full_day_v2" not in orb_src, ( diff --git a/backend/tests/test_execution_quality.py b/backend/tests/test_execution_quality.py new file mode 100644 index 0000000..61265d9 --- /dev/null +++ b/backend/tests/test_execution_quality.py @@ -0,0 +1,44 @@ +from datetime import datetime + +import pytest + +from app.services import execution_quality as eq + + +def test_side_adjust_no_cancellation(): + rows = [ + {"order_type": "BUY", "slippage": 0.1, "strategy_type": "daytrading"}, + {"order_type": "SELL", "slippage": 0.1, "strategy_type": "daytrading"}, + ] + r = eq.aggregate_execution_quality_v1(rows)["overall"] + assert r["mean_cost_bp"] == 0.0 + assert r["mean_abs_bp"] == 10.0 + assert eq.slippage_cost_bp_v3("BUY", 0.1) == pytest.approx(10.0) + assert eq.slippage_cost_bp_v3("SELL", 0.1) == pytest.approx(-10.0) + + +def test_matches_trade_executor_v2(): + te = pytest.importorskip("app.services.trade_executor") + for side in ("BUY", "SELL", "HOLD"): + for v in (0.1, -0.3, None, "x"): + assert eq.slippage_cost_bp_v3(side, v) == te.slippage_cost_bp_v2(side, v) + + +def test_low_sample_null_and_groups(): + rows = [ + {"order_type": "BUY", "slippage": None, "strategy_type": "swing", "executed_quantity": 0}, + {"order_type": "BUY", "slippage": 0.2, "strategy_type": "daytrading", "executed_quantity": 5, + "executed_at": datetime(2026, 10, 7, 9, 5), "fill_verified": True}, + ] + r = eq.aggregate_execution_quality_v1(rows) + assert r["overall"]["low_sample"] and r["overall"]["n_slippage"] == 1 + assert r["by_strategy"]["swing"]["mean_cost_bp"] is None + assert r["by_hour"][9]["fill_verified_ratio"] == 1.0 + big = [{"order_type": "BUY", "slippage": 0.1}] * eq.LOW_SAMPLE_THRESHOLD + assert not eq.aggregate_execution_quality_v1(big)["overall"]["low_sample"] + + +def test_invalid_inputs(): + for bad in (None, "x", float("nan"), float("inf")): + assert eq.slippage_cost_bp_v3("BUY", bad) is None + assert eq.slippage_cost_bp_v3("HOLD", 0.1) is None diff --git a/backend/tests/test_fill_price_avg_split_20261008.py b/backend/tests/test_fill_price_avg_split_20261008.py new file mode 100644 index 0000000..ba828d8 --- /dev/null +++ b/backend/tests/test_fill_price_avg_split_20261008.py @@ -0,0 +1,479 @@ +"""2026-10-08 체결가/평단/환율/총자산 정합 회귀. + +1) 체결 확인 전 executed_price 에 주문가를 넣지 않고, 확인 시 체결가×체결수량·순손익으로 갱신 +2) 스윙 손익·손절 진입가 = 스윙 ActiveTrade.entry_price (없으면 Portfolio 평단 폴백, 괴리 시 보수적 max) +3) 환율 0 → 마지막 성공값/설정값 폴백, 모두 없으면 환산 생략 플래그 +4) 총자산 = 주식평가 + D+2 예수금 분해 필드 +""" +import asyncio +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.core.config import settings +import app.services.db_worker as dbw +import app.services.trade_executor as te +from app.services.swing import fill_sweep as fs +from app.services.swing.position_manager import ( + resolve_swing_entry_price_v2, + SWING_ENTRY_PRICE_MAX_DEVIATION, +) +from app.api.endpoints import portfolio as pf + + +def _run(coro): + return asyncio.run(coro) + + +# ── 1. 체결 기준 TradeHistory 필드 ──────────────────────────────────────────── +def test_fill_fields_buy_amount_is_fill_price_times_qty(): + f = te._fill_update_fields_v2("BUY", 9, 41200.0, -0.74, None) + assert f["executed_price"] == Decimal("41200.0") + assert f["executed_quantity"] == 9 + assert f["total_amount"] == Decimal(str(round(41200.0 * 9, 2))) + assert "net_profit_loss" not in f + + +def test_fill_fields_no_price_never_substitutes_order_price(): + f = te._fill_update_fields_v2("BUY", 9, 0.0, 0.0, None) + assert "executed_price" not in f + assert "total_amount" not in f + assert f["executed_quantity"] == 9 + + +def test_fill_fields_sell_recomputes_net_from_fill(): + basis = {"entry_price": 10000.0, "market": "KOSPI", "is_domestic": True, "net_rate": False} + f = te._fill_update_fields_v2("SELL", 10, 11000.0, 0.0, basis) + from app.services.trade_helpers import calculate_trade_costs + c = calculate_trade_costs(entry_price=10000.0, exit_price=11000.0, quantity=10, + is_domestic=True, market="KOSPI") + assert float(f["net_profit_loss"]) == pytest.approx(10000.0 - float(c["total_cost"]), abs=0.01) + assert float(f["profit_loss_rate"]) == pytest.approx(10.0, abs=1e-6) # gross 규약 + assert float(f["commission"]) == pytest.approx(float(c["total_cost"])) + + +def test_fill_fields_sell_net_rate(): + basis = {"entry_price": 10000.0, "net_rate": True} + f = te._fill_update_fields_v2("SELL", 10, 11000.0, 0.0, basis) + assert float(f["profit_loss_rate"]) == pytest.approx( + float(f["net_profit_loss"]) / 100000.0 * 100, abs=1e-3) + + +@pytest.fixture +def verify_env(monkeypatch): + calls = {"th": [], "at": []} + + async def _th(order_id, fields): + calls["th"].append((order_id, dict(fields))) + + async def _at(data): + calls["at"].append(dict(data)) + + monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _th) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _at) + monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) + monkeypatch.setattr(te.asyncio, "sleep", AsyncMock()) + te._entry_fill_adjusted_orders.clear() + return calls + + +def _patch_exec(monkeypatch, result): + """체결조회 목 — 테스트 시점에 두 참조(trade_executor 캡처본, 소스 모듈 속성)를 모두 덮는다. + + (다른 테스트 파일이 sys.modules 의 KIS 모듈을 MagicMock 으로 바꿔도 순서 무관하게 동작) + """ + import sys + m = AsyncMock(return_value=result) + stub = SimpleNamespace(get_order_execution=m) + monkeypatch.setattr(te, "korea_investment_api", stub) + mod = sys.modules.get("app.services.korea_investment_api") + if mod is not None: + monkeypatch.setattr(mod, "korea_investment_api", stub, raising=False) + return m + + +def test_verify_v3_buy_updates_amount_and_entry(monkeypatch, verify_env): + _patch_exec(monkeypatch, {"ok": True, "found": True, "filled_qty": 9, + "filled_price": 41200.0, "remaining_qty": 0}) + _run(te._verify_and_sync_execution_v3( + "0000123", "082740", "한화엔진", 9, 41506.0, "BUY", True, "KRX", 0, + fill_entry_strategy_name="swing", sell_pnl_basis={"strategy_type": "swing"}, + )) + (oid, fields), = verify_env["th"] + assert fields["executed_price"] == Decimal("41200.0") + assert fields["total_amount"] == Decimal(str(41200.0 * 9)) + assert fields["order_type"] == "BUY" and fields["strategy_type"] == "swing" + (at,) = verify_env["at"] + assert at["strategy_name"] == "swing" + assert at["entry_fill_adjust"] == {"order_price": 41506.0, "filled_price": 41200.0, "filled_qty": 9} + assert te.entry_fill_already_adjusted_v2("123") + + +def test_verify_v3_missing_fill_price_no_price_written(monkeypatch, verify_env): + _patch_exec(monkeypatch, {"ok": True, "found": True, "filled_qty": 9, + "filled_price": 0, "remaining_qty": 0}) + _run(te._verify_and_sync_execution_v3( + "0000124", "005380", "현대차", 9, 337177.0, "BUY", True, "KRX", 0, + fill_entry_strategy_name="swing", + )) + (oid, fields), = verify_env["th"] + assert "executed_price" not in fields and "total_amount" not in fields + assert verify_env["at"] == [] # 체결가 모르면 진입가 보정 없음 + + +def test_verify_v3_sell_net_from_fill(monkeypatch, verify_env): + _patch_exec(monkeypatch, {"ok": True, "found": True, "filled_qty": 5, + "filled_price": 9500.0, "remaining_qty": 0}) + _run(te._verify_and_sync_execution_v3( + "0000125", "005930", "삼성", 5, 9600.0, "SELL", True, "KRX", 0, + sell_pnl_basis={"entry_price": 10000.0, "market": "KOSPI", "strategy_type": "swing"}, + )) + (oid, fields), = verify_env["th"] + assert float(fields["net_profit_loss"]) < -2500.0 # gross -2500 에서 비용 차감 + assert float(fields["profit_loss_rate"]) == pytest.approx(-5.0) + + +# ── 1-b. ActiveTrade 진입가 누적/체결 보정 (db_worker 순수 로직) ────────────── +def _rec(**kw): + base = dict(id=7, quantity=10, entry_price=10000.0, highest_since_entry=10500.0, remaining_pct=1.0) + base.update(kw) + return SimpleNamespace(**base) + + +def test_accumulate_weighted_average_and_pinned_retry(): + r = _rec() + data = {"accumulate_entry": True, "quantity": 10, "entry_price": 8000.0, "highest_since_entry": 8000.0} + skip = dbw._apply_entry_price_adjustments_v2(r, data) + assert {"quantity", "entry_price", "highest_since_entry"} <= skip + assert r.quantity == 20 and r.entry_price == 9000.0 + assert r.highest_since_entry == 10500.0 # 고점은 낮추지 않는다 + # 같은 payload 재실행(종료 drain) — 두 번 누적하지 않는다 + r2 = _rec() + dbw._apply_entry_price_adjustments_v2(r2, data) + assert r2.quantity == 20 and r2.entry_price == 9000.0 + + +def test_accumulate_after_partial_exit_weights_held_only(): + r = _rec(remaining_pct=0.5) # 10주 중 5주 보유 + dbw._apply_entry_price_adjustments_v2(r, {"accumulate_entry": True, "quantity": 5, "entry_price": 8000.0}) + assert r.entry_price == 9000.0 # (5×10000 + 5×8000)/10 + + +def test_accumulate_into_empty_row_takes_new_price(): + r = _rec(quantity=0, entry_price=0) + dbw._apply_entry_price_adjustments_v2(r, {"accumulate_entry": True, "quantity": 3, "entry_price": 5000.0}) + assert r.quantity == 3 and r.entry_price == 5000.0 + + +def test_fill_adjust_new_entry_becomes_fill_price(): + r = _rec(quantity=9, entry_price=41506.0) + d = {"entry_fill_adjust": {"order_price": 41506.0, "filled_price": 41200.0, "filled_qty": 9}} + dbw._apply_entry_price_adjustments_v2(r, d) + assert r.entry_price == 41200.0 + # 재실행 시 고정값 재사용(두 번 빼지 않음) + r.entry_price = 41506.0 + dbw._apply_entry_price_adjustments_v2(r, d) + assert r.entry_price == 41200.0 + + +def test_fill_adjust_partial_of_accumulated_row(): + r = _rec(quantity=20, entry_price=9000.0) # 10@10000 + 10@8000(주문가) + dbw._apply_entry_price_adjustments_v2( + r, {"entry_fill_adjust": {"order_price": 8000.0, "filled_price": 7900.0, "filled_qty": 10}}) + assert r.entry_price == 8950.0 + + +def test_fill_adjust_bad_input_keeps_price(): + r = _rec() + dbw._apply_entry_price_adjustments_v2( + r, {"entry_fill_adjust": {"order_price": 0, "filled_price": 7900.0, "filled_qty": 10}}) + assert r.entry_price == 10000.0 + + +def test_upsert_handler_skips_fill_adjust_when_no_row(): + class _S: + async def execute(self, q): + return SimpleNamespace(scalars=lambda: SimpleNamespace(first=lambda: None)) + + def add(self, x): # pragma: no cover + raise AssertionError("must not create") + + async def commit(self): # pragma: no cover + raise AssertionError("must not commit") + + _run(dbw._handle_upsert_active_trade(_S(), { + "stock_code": "005930", "strategy_name": "swing", + "entry_fill_adjust": {"order_price": 1, "filled_price": 1, "filled_qty": 1}, + })) + + +# ── 2. 스윙 진입가 해석 (손절 판정 직결 — 경계값) ──────────────────────────── +def test_entry_price_prefers_active_trade_within_band(): + assert resolve_swing_entry_price_v2(68000, 67323.08) == (68000.0, "active_trade") + + +def test_entry_price_boundary_exact_deviation_uses_active_trade(): + pa = 10000.0 + at = pa * (1 + SWING_ENTRY_PRICE_MAX_DEVIATION) + price, basis = resolve_swing_entry_price_v2(at, pa) + assert basis == "active_trade" and price == pytest.approx(at) + + +def test_entry_price_over_deviation_uses_kis_portfolio_avg(): + # [2026-10-09 하드닝 R1] 괴리 > 한도면 유령 행 의심 → KIS 실제 평단(Portfolio). + # 이전 정책(max)은 유령 고가 entry 로 허위 전량 손절을 냈다. + pa = 10000.0 + lo = pa * (1 - SWING_ENTRY_PRICE_MAX_DEVIATION - 0.01) + assert resolve_swing_entry_price_v2(lo, pa) == (pa, "portfolio_avg_deviation") + hi = pa * (1 + SWING_ENTRY_PRICE_MAX_DEVIATION + 0.01) + assert resolve_swing_entry_price_v2(hi, pa) == (pa, "portfolio_avg_deviation") + + +@pytest.mark.parametrize("at,pa,exp", [ + (None, 5000, (5000.0, "portfolio_avg")), + (0, 5000, (5000.0, "portfolio_avg")), + ("bad", 5000, (5000.0, "portfolio_avg")), + (5000, 0, (5000.0, "active_trade")), + (None, None, (0.0, "none")), +]) +def test_entry_price_fallbacks(at, pa, exp): + assert resolve_swing_entry_price_v2(at, pa) == exp + + +def test_executor_entry_price_dt_tag_uses_portfolio(): + h = SimpleNamespace(average_price=Decimal("67323.08")) + assert te._swing_entry_price_for_stock_v2( + {"strategy_type": "daytrading", "_swing_entry_price": 68000}, h, "103140") == 67323.08 + assert te._swing_entry_price_for_stock_v2( + {"_swing_entry_price": 68000}, h, "103140") == 68000.0 + assert te._swing_entry_price_for_stock_v2({}, h, "103140") == 67323.08 + + +def test_mixed_avg_stop_loss_trigger_differs(): + """혼합 평단(67,323)으로는 -7% 미발동인데 스윙 진입가(68,000)로는 발동 — 스윙 기준이 맞다.""" + stop = -abs(float(settings.STOP_LOSS_PERCENT)) + price = 68000 * (1 + stop / 100) - 1 + swing_pl = (price / 68000 - 1) * 100 + mixed_pl = (price / 67323.08 - 1) * 100 + assert swing_pl <= stop < mixed_pl + + +# ── 2-b. API 전략별 분해 필드 ──────────────────────────────────────────────── +def _h(**kw): + base = dict(stock_code="103140", quantity=13, average_price=Decimal("67323.08"), + current_price=Decimal("70000")) + base.update(kw) + return SimpleNamespace(**base) + + +def test_breakdown_swing_with_dt(): + b = pf._strategy_breakdown_v2(_h(), 68000.0, {"quantity": 8, "entry_price": 66900.0}) + assert b["swing_quantity"] == 5 and b["dt_quantity"] == 8 + assert b["swing_avg_price"] == 68000.0 and b["swing_avg_price_basis"] == "active_trade" + assert b["dt_avg_price"] == 66900.0 + assert b["swing_profit_loss"] == pytest.approx(10000.0) + assert b["avg_price_mixed"] is True + + +def test_breakdown_derived_when_no_active_trade(): + b = pf._strategy_breakdown_v2(_h(), None, {"quantity": 8, "entry_price": 66900.0}) + assert b["swing_avg_price_basis"] == "derived_from_mixed" + assert b["swing_avg_price"] == pytest.approx(round((67323.08 * 13 - 66900 * 8) / 5, 2)) + + +def test_breakdown_no_dt_portfolio_fallback_rounded(): + b = pf._strategy_breakdown_v2(_h(quantity=5, average_price=Decimal("67323.0769")), None, None) + assert b["swing_avg_price"] == 67323.08 and b["swing_avg_price_basis"] == "portfolio_avg" + assert b["dt_quantity"] == 0 and b["avg_price_mixed"] is False + + +def test_breakdown_dt_only(): + b = pf._strategy_breakdown_v2(_h(quantity=8), None, {"quantity": 8, "entry_price": 66900.0}) + assert b["swing_quantity"] == 0 and b["swing_avg_price"] is None + + +def test_quantize_price_policy(): + assert pf._quantize_price_v2("67323.0769") == Decimal("67323.08") + assert pf._quantize_price_v2("1.005") == Decimal("1.01") + assert pf._quantize_price_v2(None) == Decimal("0.00") + + +def test_response_model_has_swing_avg_price(): + assert "swing_avg_price" in pf.PortfolioResponse.model_fields + + +# ── 3. 환율 ────────────────────────────────────────────────────────────────── +@pytest.fixture +def fx_reset(monkeypatch): + monkeypatch.setattr(pf, "_last_good_fx_rate", 0.0) + yield + + +def test_fx_from_kis_and_last_good(fx_reset, monkeypatch): + assert pf._resolve_fx_rate_v2({"output2": [{"bass_exrt": "1395.5"}]}) == (1395.5, "kis") + assert pf._resolve_fx_rate_v2({"output2": []}) == (1395.5, "last_good") + + +def test_fx_out_of_range_ignored_then_config(fx_reset): + rate, src = pf._resolve_fx_rate_v2({"output2": {"bass_exrt": "3.2"}}) + assert src == "config_default" and rate == float(settings.US_DEFAULT_FX_RATE) + + +def test_fx_unavailable_when_all_zero(fx_reset, monkeypatch): + monkeypatch.setattr(settings, "US_DEFAULT_FX_RATE", 0.0) + assert pf._resolve_fx_rate_v2(None) == (0.0, "unavailable") + + +def test_fx_derived_ratio_no_double_count(fx_reset): + data = {"output2": {"tot_evlu_amt": "1400000", "frcr_evlu_tota": "1000", "usd_amt": "1000"}} + assert pf._resolve_fx_rate_v2(data) == (1400.0, "kis") + + +def test_cache_summary_v2_fx_and_cash_fields(fx_reset, monkeypatch): + cache = pf.PortfolioCache() + cache.holdings = [ + SimpleNamespace(stock_code="005930", market="KRX", quantity=10, average_price=Decimal("70000"), + current_price=Decimal("71000"), strategy_type="swing", profit_loss_rate=None), + SimpleNamespace(stock_code="AAPL", market="NASD", quantity=2, average_price=Decimal("150"), + current_price=Decimal("200"), strategy_type="swing", profit_loss_rate=None), + ] + bal = {"output2": [{"tot_evlu_amt": "1000000", "scts_evlu_amt": "710000", + "prvs_rcdl_excc_amt": "290000", "_ord_psbl_cash": "264854", + "dnca_tot_amt": "290000"}]} + _run(cache.cache_summary_v2(bal, {"output2": []})) + s = cache.summary + # [R1 2026-10-09] 설정 기본값 환율은 추정치 — 총자산·원화 환산에 합산하지 않고 별도 노출 + assert s["exchange_rate"] == 0.0 + assert s["exchange_rate_estimate"] == float(settings.US_DEFAULT_FX_RATE) + assert s["exchange_rate_source"] == "config_default" and s["exchange_rate_unavailable"] is True + assert s["overseas_value_usd"] == 400.0 + assert s["overseas_value_krw"] == 0.0 + assert s["available_cash"] == 264854.0 # 주문가능현금(매매 사이징 입력) — 의미 불변 + assert s["settlement_cash"] == 290000.0 + assert s["cash_basis_gap"] == pytest.approx(25146.0) + assert s["cash_for_total_assets"] == 290000.0 + assert s["total_assets_residual"] == 0.0 + + +def test_cache_summary_v2_fx_unavailable_flag(fx_reset, monkeypatch): + monkeypatch.setattr(settings, "US_DEFAULT_FX_RATE", 0.0) + cache = pf.PortfolioCache() + cache.holdings = [SimpleNamespace(stock_code="AAPL", market="NASD", quantity=2, + average_price=Decimal("150"), current_price=Decimal("200"), + strategy_type="swing", profit_loss_rate=None)] + _run(cache.cache_summary_v2(None, None)) + s = cache.summary + assert s["exchange_rate"] == 0.0 and s["exchange_rate_unavailable"] is True + assert s["fx_conversion_skipped"] is True and s["overseas_value_krw"] == 0.0 + + +# ── 1-c. 후속 체결 보정(fill_sweep) ────────────────────────────────────────── +def test_derive_sell_entry_price(): + row = SimpleNamespace(order_quantity=10, total_amount=Decimal("110000"), + net_profit_loss=Decimal("9790"), commission=Decimal("210")) + assert fs.derive_sell_entry_price_v2(row) == pytest.approx(10000.0) + row2 = SimpleNamespace(order_quantity=10, total_amount=110000, net_profit_loss=None, commission=None) + assert fs.derive_sell_entry_price_v2(row2) is None + + +class _FakeDB: + """1번째 execute = TradeHistory 후보, 이후 = ActiveTrade 조회(진입가 보정 대상 확정).""" + def __init__(self, rows, active_rows=None): + self.rows = rows + self.active_rows = list(active_rows or []) + self.calls = 0 + + async def execute(self, q): + self.calls += 1 + rows = self.rows if self.calls == 1 else self.active_rows + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) + + +def _th_row(**kw): + base = dict(id=448, stock_code="082740", order_type="BUY", order_id="0000777", + order_quantity=9, order_price=Decimal("41506"), total_amount=Decimal("373554"), + net_profit_loss=None, commission=None, strategy_type="swing", + entry_reason="RSI 과매도 반등") + base.update(kw) + return SimpleNamespace(**base) + + +@pytest.fixture +def sweep_env(monkeypatch): + calls = {"th": [], "at": []} + + async def _th(tid, fields): + calls["th"].append((tid, dict(fields))) + + async def _at(data): + calls["at"].append(dict(data)) + + monkeypatch.setattr(dbw, "enqueue_update_trade_history", _th) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _at) + fs._attempts.clear() + fs._queued_ids.clear() + te._entry_fill_adjusted_orders.clear() + return calls + + +def test_sweep_buy_updates_price_amount_and_entry(monkeypatch, sweep_env): + _patch_exec(monkeypatch, {"ok": True, "found": True, "filled_qty": 9, + "filled_price": 41200.0, "remaining_qty": 0}) + active = [SimpleNamespace(id=225, strategy_name="swing", entry_reason="RSI 과매도 반등")] + n = _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_th_row()], active))) + assert n == 1 + (tid, f), = sweep_env["th"] + assert tid == 448 and f["executed_price"] == Decimal("41200.0") + assert f["total_amount"] == Decimal(str(41200.0 * 9)) + assert "executed_at" not in f + (at,) = sweep_env["at"] + # [하드닝 R1 2026-10-09] '최신 비-DT 행'이 아니라 이 매수의 전략명으로 정확히 지정 + assert at["strategy_name"] == "swing" and "strategy_name_not" not in at + assert at["entry_fill_adjust"]["filled_price"] == 41200.0 + + +def test_sweep_buy_skips_entry_adjust_if_already_done(monkeypatch, sweep_env): + te.note_entry_fill_adjusted_v2("777") + _patch_exec(monkeypatch, {"ok": True, "found": True, "filled_qty": 9, + "filled_price": 41200.0, "remaining_qty": 0}) + _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_th_row()]))) + assert len(sweep_env["th"]) == 1 and sweep_env["at"] == [] + + +def test_sweep_no_fill_price_no_update_and_attempts_capped(monkeypatch, sweep_env): + _patch_exec(monkeypatch, {"ok": True, "found": True, "filled_qty": 9, + "filled_price": 0, "remaining_qty": 0}) + monkeypatch.setattr(fs, "FILL_SWEEP_RETRY_INTERVAL_SEC", 0) + for _ in range(fs.FILL_SWEEP_MAX_ATTEMPTS + 2): + _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_th_row()]))) + assert sweep_env["th"] == [] + assert fs._attempts[448][0] == fs.FILL_SWEEP_MAX_ATTEMPTS + assert 448 in fs._exhausted_ids() + + +def test_sweep_sell_recomputes_net(monkeypatch, sweep_env): + row = _th_row(id=500, order_type="SELL", order_quantity=10, order_price=Decimal("11000"), + total_amount=Decimal("110000"), net_profit_loss=Decimal("10000"), commission=None) + _patch_exec(monkeypatch, {"ok": True, "found": True, "filled_qty": 10, + "filled_price": 10900.0, "remaining_qty": 0}) + _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([row]))) + (tid, f), = sweep_env["th"] + assert f["total_amount"] == Decimal("109000.0") + assert float(f["net_profit_loss"]) < 9000.0 # (10900-10000)×10 − 비용 + assert float(f["profit_loss_rate"]) == pytest.approx(9.0) + assert sweep_env["at"] == [] + + +def test_sweep_partial_unsettled_skipped(monkeypatch, sweep_env): + _patch_exec(monkeypatch, {"ok": True, "found": True, "filled_qty": 4, + "filled_price": 41200.0, "remaining_qty": 5}) + _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_th_row()]))) + assert sweep_env["th"] == [] + + +def test_sweep_skips_overseas(monkeypatch, sweep_env): + m = _patch_exec(monkeypatch, {"ok": True}) + _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_th_row(stock_code="AAPL")]))) + m.assert_not_called() diff --git a/backend/tests/test_fill_sweep_idempotent_r5_20261009.py b/backend/tests/test_fill_sweep_idempotent_r5_20261009.py new file mode 100644 index 0000000..c6c8a60 --- /dev/null +++ b/backend/tests/test_fill_sweep_idempotent_r5_20261009.py @@ -0,0 +1,120 @@ +"""[하드닝 R5 2026-10-09] swing/fill_sweep BUY 진입가 보정(entry_fill_adjust, 비멱등) 재보정 방지. + +보정 태스크가 큐에 머무는/DLQ 재시도 중인 동안 TradeHistory 행은 여전히 '미확인'으로 조회된다. +이전에는 note_entry_fill_adjusted_v2 미호출 + _attempts.pop 으로 다음 사이클이 같은 주문을 다시 +보정해 진입가·손절선이 누적 이동했다. +""" +import asyncio +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import app.services.db_worker as dbw +import app.services.trade_executor as te +from app.services.swing import fill_sweep as fs + +import sys + + +class _FakeDB: + """매 sweep: 홀수번째 execute = TradeHistory 후보(워커 미반영이라 계속 같은 행), 그 외 = ActiveTrade.""" + + def __init__(self, rows, active_rows): + self.rows = rows + self.active_rows = active_rows + self.queries = [] + self._th_next = True + + async def execute(self, q): + self.queries.append(q) + sql = str(q) + rows = self.rows if "trade_history" in sql else self.active_rows + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) + + +def _th_row(**kw): + base = dict(id=901, stock_code="005380", order_type="BUY", order_id="0000777", + order_quantity=3, order_price=Decimal("337177"), total_amount=Decimal("1011531"), + net_profit_loss=None, commission=None, strategy_type="swing", + entry_reason="평균회귀 RSI 과매도", created_at=None) + base.update(kw) + return SimpleNamespace(**base) + + +_FILLED = {"ok": True, "found": True, "filled_qty": 3, "filled_price": 335500.0, "remaining_qty": 0} + + +@pytest.fixture +def env(monkeypatch): + calls = {"th": [], "at": []} + + async def _th(tid, fields): + calls["th"].append((tid, dict(fields))) + + async def _at_up(data): + calls["at"].append(dict(data)) + + monkeypatch.setattr(dbw, "enqueue_update_trade_history", _th) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _at_up) + m = AsyncMock(return_value=_FILLED) + stub = SimpleNamespace(get_order_execution=m) + monkeypatch.setattr(te, "korea_investment_api", stub) + mod = sys.modules.get("app.services.korea_investment_api") + if mod is not None: + monkeypatch.setattr(mod, "korea_investment_api", stub, raising=False) + monkeypatch.setattr(fs, "_auction_fill_pending_v2", lambda *_a, **_k: False) + fs._attempts.clear() + fs._queued_ids.clear() + fs._last_stats.update(day="", candidates=0, exhausted=0, queued=0, exhausted_warned=0) + te._entry_fill_adjusted_orders.clear() + calls["exec"] = m + return calls + + +def _db(): + return _FakeDB([_th_row()], [SimpleNamespace(id=216, strategy_name="swing", + entry_reason="평균회귀 RSI 과매도")]) + + +def test_entry_adjust_marks_order_and_not_requeued_next_cycle(env): + db = _db() + assert asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) == 1 + assert len(env["at"]) == 1 + assert te.entry_fill_already_adjusted_v2("0000777") + # 워커가 아직 반영하지 않아 같은 행이 다시 후보로 조회돼도 재보정·재조회 없음 + assert asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) == 0 + assert len(env["at"]) == 1 + assert env["exec"].await_count == 1 + + +def test_entry_adjust_skipped_even_if_queued_ids_lost(env): + """행 id 표시가 사라져도(재조회 경로) 주문번호 표시로 진입가 보정은 1회만.""" + db = _db() + asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) + fs._queued_ids.clear() + asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) + assert len(env["at"]) == 1 + assert len(env["th"]) == 2 # TradeHistory 갱신은 절대값 덮어쓰기라 멱등 + + +def test_mark_set_before_enqueue_failure(env, monkeypatch): + """큐잉이 예외로 실패해도 표시가 남아 이중 보정 경로가 열리지 않는다(유실 쪽으로 실패).""" + async def _boom(_data): + raise RuntimeError("queue full") + + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _boom) + with pytest.raises(RuntimeError): + asyncio.run(fs.sweep_unconfirmed_fills_v2(_db())) + assert te.entry_fill_already_adjusted_v2("0000777") + assert 901 in fs._queued_ids + + +def test_query_excludes_queued_ids(env): + db = _db() + asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) + asyncio.run(fs.sweep_unconfirmed_fills_v2(db)) + th_queries = [q for q in db.queries if "trade_history" in str(q)] + compiled = th_queries[-1].compile(compile_kwargs={"literal_binds": True}) + assert "901" in str(compiled) diff --git a/backend/tests/test_fill_sweep_post_close_r4.py b/backend/tests/test_fill_sweep_post_close_r4.py new file mode 100644 index 0000000..bdff802 --- /dev/null +++ b/backend/tests/test_fill_sweep_post_close_r4.py @@ -0,0 +1,217 @@ +"""[하드닝 R4 2026-10-09] 종가 동시호가(15:20~15:30) 스윙 주문 체결가 확인. + +결함: sweep 이 장중(is_market_open, 15:30 미만)에만 돌아 15:28 주문은 sweep 기회 0회 → +executed_price 영구 NULL(ActiveTrade 진입가가 주문가로 남아 손절 기준 이동). +수정: 장 마감 직후 창(15:30 ~ +SWING_FILL_SWEEP_POST_CLOSE_MINUTES)에 장외 분기에서 sweep, +동시호가 주문은 15:30 전 조회 보류(시도 횟수 보존). +""" +import asyncio +import sys +from datetime import datetime, timedelta +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import app.core.database as database_mod +import app.core.market_schedule as ms +import app.services.db_worker as dbw +import app.services.trade_executor as te +from app.core.config import settings +from app.services.swing import fill_sweep as fs + +# 2026-10-07 = 수요일(운영 관측일) +_DAY = datetime(2026, 10, 7) + + +def _at(h, m, s=0): + return _DAY.replace(hour=h, minute=m, second=s) + + +def _run(coro): + return asyncio.run(coro) + + +class _StopLoop(BaseException): + pass + + +class _FakeDB: + def __init__(self, rows, active_rows=None): + self.rows = rows + self.active_rows = list(active_rows or []) + self.n = 0 + + async def execute(self, q): + self.n += 1 + rows = self.rows if self.n == 1 else self.active_rows + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) + + +def _row(created_at, **kw): + base = dict(id=900, stock_code="005380", order_type="BUY", order_id="0001234", + order_quantity=3, order_price=Decimal("337177"), total_amount=Decimal("1011531"), + net_profit_loss=None, commission=None, strategy_type="swing", + entry_reason="평균회귀 RSI 과매도", created_at=created_at) + base.update(kw) + return SimpleNamespace(**base) + + +def _patch_exec(monkeypatch, mock): + stub = SimpleNamespace(get_order_execution=mock) + monkeypatch.setattr(te, "korea_investment_api", stub) + mod = sys.modules.get("app.services.korea_investment_api") + if mod is not None: + monkeypatch.setattr(mod, "korea_investment_api", stub, raising=False) + return mock + + +@pytest.fixture +def env(monkeypatch): + calls = {"th": [], "at": []} + + async def _th(tid, fields): + calls["th"].append((tid, dict(fields))) + + async def _at_up(data): + calls["at"].append(dict(data)) + + monkeypatch.setattr(dbw, "enqueue_update_trade_history", _th) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _at_up) + monkeypatch.setattr(ms, "is_krx_holiday", lambda d: False) + fs._attempts.clear() + fs._queued_ids.clear() + fs._last_stats.update(day="", candidates=0, exhausted=0, queued=0, exhausted_warned=0) + te._entry_fill_adjusted_orders.clear() + return calls + + +# ── 창 판정 ────────────────────────────────────────────────────────────────── +def test_post_close_window_bounds(env, monkeypatch): + mins = int(getattr(settings, "SWING_FILL_SWEEP_POST_CLOSE_MINUTES", fs._DEFAULT_POST_CLOSE_MINUTES)) + assert not fs.post_close_sweep_active_v2(_at(15, 29, 59)) + assert fs.post_close_sweep_active_v2(_at(15, 30)) + assert fs.post_close_sweep_active_v2(_at(15, 30) + timedelta(minutes=mins) - timedelta(seconds=1)) + assert not fs.post_close_sweep_active_v2(_at(15, 30) + timedelta(minutes=mins)) + # 주말·휴장일 제외 + assert not fs.post_close_sweep_active_v2(datetime(2026, 10, 10, 15, 35)) # 토 + monkeypatch.setattr(ms, "is_krx_holiday", lambda d: True) + assert not fs.post_close_sweep_active_v2(_at(15, 35)) + + +def test_post_close_window_follows_settings(env, monkeypatch): + # config.py 에 키가 아직 없다(Settings 는 미정의 필드 setattr 거부) — 설정 조회 지점을 덮는다 + monkeypatch.setattr(fs, "_settings_num_v2", + lambda n, d: 90.0 if n == "SWING_FILL_SWEEP_POST_CLOSE_MINUTES" else d) + assert fs.post_close_sweep_active_v2(_at(16, 50)) + assert not fs.post_close_sweep_active_v2(_at(17, 0)) + + +# ── 동시호가 주문 조회 보류 ────────────────────────────────────────────────── +def test_auction_order_not_looked_up_before_close(env, monkeypatch): + m = _patch_exec(monkeypatch, AsyncMock(side_effect=AssertionError("15:30 전 동시호가 주문 조회"))) + monkeypatch.setattr(fs, "now_kst", lambda: _at(15, 26)) + assert _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_row(_at(15, 22, 6))]))) == 0 + m.assert_not_called() + assert 900 not in fs._attempts # 시도 횟수 보존 + + +def test_continuous_session_order_still_looked_up_in_auction(env, monkeypatch): + m = _patch_exec(monkeypatch, AsyncMock(return_value={"ok": True, "found": True, "filled_qty": 0, + "filled_price": 0, "remaining_qty": 3})) + monkeypatch.setattr(fs, "now_kst", lambda: _at(15, 26)) + _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_row(_at(14, 0))]))) + m.assert_awaited_once() + + +def test_auction_order_confirmed_after_close(env, monkeypatch): + _patch_exec(monkeypatch, AsyncMock(return_value={"ok": True, "found": True, "filled_qty": 3, + "filled_price": 335500.0, "remaining_qty": 0})) + monkeypatch.setattr(fs, "now_kst", lambda: _at(15, 31)) + active = [SimpleNamespace(id=216, strategy_name="swing", entry_reason="평균회귀 RSI 과매도")] + assert _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_row(_at(15, 28, 16))], active))) == 1 + (tid, f), = env["th"] + assert tid == 900 and f["executed_price"] == Decimal("335500.0") + (adj,) = env["at"] + assert adj["strategy_name"] == "swing" + assert adj["entry_fill_adjust"]["filled_price"] == 335500.0 + + +# ── 장 마감 후 실행기 ──────────────────────────────────────────────────────── +def test_runner_outside_window_touches_nothing(env, monkeypatch): + def _boom(): + raise AssertionError("창 밖에서 세션 생성") + + monkeypatch.setattr(database_mod, "AsyncSessionLocal", _boom) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(side_effect=AssertionError("창 밖 sweep"))) + assert _run(fs.run_post_close_fill_sweep_v2(_at(15, 0))) is None + assert _run(fs.run_post_close_fill_sweep_v2(_at(18, 0))) is None + + +class _CM: + def __init__(self): + self.session = object() + + async def __aenter__(self): + return self.session + + async def __aexit__(self, *a): + return False + + +def test_runner_in_window_sweeps_and_caps_sleep(env, monkeypatch): + cm = _CM() + monkeypatch.setattr(database_mod, "AsyncSessionLocal", lambda: cm) + sweep = AsyncMock(return_value=1) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", sweep) + interval = float(getattr(settings, "SWING_FILL_SWEEP_POST_CLOSE_INTERVAL_SEC", + fs._DEFAULT_POST_CLOSE_INTERVAL_SEC)) + assert _run(fs.run_post_close_fill_sweep_v2(_at(15, 31))) == interval + sweep.assert_awaited_once_with(cm.session) + + +def test_runner_absorbs_sweep_failure(env, monkeypatch, caplog): + monkeypatch.setattr(database_mod, "AsyncSessionLocal", lambda: _CM()) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(side_effect=RuntimeError("db down"))) + with caplog.at_level("WARNING", logger=fs.logger.name): + assert _run(fs.run_post_close_fill_sweep_v2(_at(15, 40))) is not None + assert any("장 마감 후 체결가 보정 실패" in r.getMessage() for r in caplog.records) + + +# ── 스케줄러 장외 분기 배선 ────────────────────────────────────────────────── +def _closed_loop(monkeypatch, post_close_result): + import app.services.swing.scheduler as sch + slept = [] + + async def _sleep(sec): + if sec < 10: # 기동 지연(2초) 통과 + return + slept.append(sec) + raise _StopLoop() + + monkeypatch.setattr(sch.asyncio, "sleep", _sleep) + monkeypatch.setattr(sch, "is_market_open", lambda: False) + monkeypatch.setattr(sch, "_check_mdd_threshold_knobs_v2", AsyncMock(return_value=[])) + monkeypatch.setattr(ms, "seconds_until_market_open", lambda: 60000.0) + monkeypatch.setattr(fs, "run_post_close_fill_sweep_v2", post_close_result) + with pytest.raises(_StopLoop): + _run(sch.swing_scheduler()) + return slept + + +def test_scheduler_closed_branch_runs_post_close_sweep(monkeypatch): + runner = AsyncMock(return_value=60.0) + slept = _closed_loop(monkeypatch, runner) + runner.assert_awaited_once() + assert slept[-1] == 60.0 + + +def test_scheduler_closed_branch_outside_window_keeps_long_sleep(monkeypatch): + slept = _closed_loop(monkeypatch, AsyncMock(return_value=None)) + assert slept[-1] == 1800.0 + + +def test_scheduler_closed_branch_survives_runner_error(monkeypatch): + slept = _closed_loop(monkeypatch, AsyncMock(side_effect=RuntimeError("boom"))) + assert slept[-1] == 1800.0 diff --git a/backend/tests/test_fill_sweep_target_20261009.py b/backend/tests/test_fill_sweep_target_20261009.py new file mode 100644 index 0000000..ecd3310 --- /dev/null +++ b/backend/tests/test_fill_sweep_target_20261009.py @@ -0,0 +1,222 @@ +"""[하드닝 R1 2026-10-09] swing/fill_sweep 회귀. + +1) BUY 진입가 보정 대상: '최신 비-DT active 행'(synced·타 전략 오보정)이 아니라 + entry_reason 으로 확정한 이 매수의 전략명(strategy_name 동등). 확정 불가면 보정 안 함. +2) 미확인 판정: executed_price NULL + 레거시(체결가=주문가 & slippage NULL) 포함, 일일 지표. +""" +import asyncio +import logging +import sys +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy.dialects import postgresql + +import app.services.db_worker as dbw +import app.services.trade_executor as te +from app.services.swing import fill_sweep as fs + + +def _run(coro): + return asyncio.run(coro) + + +class _FakeDB: + """1번째 execute = TradeHistory 후보, 이후 = ActiveTrade 조회. 쿼리를 기록한다.""" + + def __init__(self, rows, active_rows=None, active_exc=None): + self.rows = rows + self.active_rows = list(active_rows or []) + self.active_exc = active_exc + self.queries = [] + + async def execute(self, q): + self.queries.append(q) + if len(self.queries) == 1: + rows = self.rows + else: + if self.active_exc is not None: + raise self.active_exc + rows = self.active_rows + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) + + +def _th_row(**kw): + base = dict(id=433, stock_code="005380", order_type="BUY", order_id="0000901", + order_quantity=3, order_price=Decimal("337177"), total_amount=Decimal("1011531"), + net_profit_loss=None, commission=None, strategy_type="swing", + entry_reason="평균회귀 RSI 과매도") + base.update(kw) + return SimpleNamespace(**base) + + +def _at(id_, name, reason): + return SimpleNamespace(id=id_, strategy_name=name, entry_reason=reason) + + +def _patch_exec(monkeypatch, result): + m = AsyncMock(return_value=result) + stub = SimpleNamespace(get_order_execution=m) + monkeypatch.setattr(te, "korea_investment_api", stub) + mod = sys.modules.get("app.services.korea_investment_api") + if mod is not None: + monkeypatch.setattr(mod, "korea_investment_api", stub, raising=False) + return m + + +_FILLED = {"ok": True, "found": True, "filled_qty": 3, "filled_price": 335500.0, "remaining_qty": 0} + + +@pytest.fixture +def env(monkeypatch): + calls = {"th": [], "at": []} + + async def _th(tid, fields): + calls["th"].append((tid, dict(fields))) + + async def _at_up(data): + calls["at"].append(dict(data)) + + monkeypatch.setattr(dbw, "enqueue_update_trade_history", _th) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _at_up) + fs._attempts.clear() + fs._queued_ids.clear() + fs._last_stats.update(day="", candidates=0, exhausted=0, queued=0, exhausted_warned=0) + te._entry_fill_adjusted_orders.clear() + return calls + + +# ── 1. 대상 행 확정 (순수 함수) ─────────────────────────────────────────────── +def test_resolve_skips_synced_and_picks_matching_swing(): + rows = [_at(217, "synced", "외부 유입 포지션 — 동기화 시 자동 등록"), + _at(216, "swing", "평균회귀 RSI 과매도")] + assert fs.resolve_entry_fill_target_v2(_th_row(), rows) == ("swing", "ok") + + +def test_resolve_only_synced_active_returns_none(): + # 운영 005380: 스윙 행 closed, synced 행만 active — 구 경로는 synced 행을 보정했다 + rows = [_at(217, "synced", "외부 유입 포지션 — 동기화 시 자동 등록")] + name, why = fs.resolve_entry_fill_target_v2(_th_row(), rows) + assert name is None and "없음" in why + + +def test_resolve_legacy_seeded_swing_name_excluded_by_reason(): + # 7abbb34 이전 시딩은 strategy_name='swing' — entry_reason 접두사로 걸러야 한다 + row = _th_row(entry_reason="외부 유입 포지션 — 동기화 시 자동 등록") + rows = [_at(10, "swing", "외부 유입 포지션 — 동기화 시 자동 등록")] + assert fs.resolve_entry_fill_target_v2(row, rows)[0] is None + + +def test_resolve_dual_strategy_newer_row_does_not_steal(): + rows = [_at(300, "swing", "평균회귀 RSI 과매도"), + _at(301, "MOMENTUM_BREAKOUT", "20일 신고가 돌파")] + assert fs.resolve_entry_fill_target_v2(_th_row(), rows)[0] == "swing" + row_m = _th_row(entry_reason="20일 신고가 돌파") + assert fs.resolve_entry_fill_target_v2(row_m, rows)[0] == "MOMENTUM_BREAKOUT" + + +def test_resolve_same_name_newer_unmatched_row_returns_none(): + # db_worker 는 같은 전략명 중 id 최대 행을 고른다 — 그 행이 이 매수 행이 아니면 보정 금지 + rows = [_at(300, "swing", "평균회귀 RSI 과매도"), _at(305, "swing", "다른 사유")] + name, why = fs.resolve_entry_fill_target_v2(_th_row(), rows) + assert name is None and "최신" in why + + +def test_resolve_ambiguous_names_or_missing_reason_returns_none(): + rows = [_at(300, "swing", "평균회귀 RSI 과매도"), + _at(301, "PULLBACK_BUY", "평균회귀 RSI 과매도")] + assert fs.resolve_entry_fill_target_v2(_th_row(), rows)[0] is None + assert fs.resolve_entry_fill_target_v2(_th_row(entry_reason=None), rows)[0] is None + # strategy_name NULL 행은 동등 필터로 지정할 수 없다 + assert fs.resolve_entry_fill_target_v2(_th_row(), [_at(1, None, "평균회귀 RSI 과매도")])[0] is None + # DT 행은 대상이 아니다 + assert fs.resolve_entry_fill_target_v2( + _th_row(), [_at(1, dbw.DT_STRATEGY_NAMES[0], "평균회귀 RSI 과매도")])[0] is None + + +# ── 1-b. sweep 종단 ────────────────────────────────────────────────────────── +def test_sweep_adjusts_exact_strategy_name_not_latest_non_dt(monkeypatch, env): + _patch_exec(monkeypatch, _FILLED) + db = _FakeDB([_th_row()], [_at(217, "synced", "외부 유입 포지션 — 동기화 시 자동 등록"), + _at(216, "swing", "평균회귀 RSI 과매도")]) + assert _run(fs.sweep_unconfirmed_fills_v2(db)) == 1 + (at,) = env["at"] + assert at["strategy_name"] == "swing" + assert "strategy_name_not" not in at and "strategy_names" not in at + assert at["entry_fill_adjust"] == {"order_price": 337177.0, "filled_price": 335500.0, + "filled_qty": 3} + # ActiveTrade 조회는 비-DT + active 조건 + sql = str(db.queries[1].compile(dialect=postgresql.dialect())) + assert "active_trades" in sql and "NOT IN" in sql.upper() + + +def test_sweep_no_target_updates_history_only_and_warns(monkeypatch, env, caplog): + _patch_exec(monkeypatch, _FILLED) + db = _FakeDB([_th_row()], [_at(217, "synced", "외부 유입 포지션 — 동기화 시 자동 등록")]) + with caplog.at_level(logging.WARNING, logger=fs.logger.name): + assert _run(fs.sweep_unconfirmed_fills_v2(db)) == 1 + assert len(env["th"]) == 1 and env["at"] == [] + assert any("진입가 보정 생략" in r.getMessage() for r in caplog.records) + + +def test_sweep_active_lookup_failure_no_adjust(monkeypatch, env, caplog): + _patch_exec(monkeypatch, _FILLED) + db = _FakeDB([_th_row()], active_exc=RuntimeError("db down")) + with caplog.at_level(logging.WARNING, logger=fs.logger.name): + assert _run(fs.sweep_unconfirmed_fills_v2(db)) == 1 + assert len(env["th"]) == 1 and env["at"] == [] + assert any("조회 실패" in r.getMessage() and r.levelno >= logging.WARNING + for r in caplog.records) + + +def test_enqueue_without_db_never_adjusts(env): + ok = _run(fs._enqueue_buy_entry_adjust_v2(_th_row(), 3, 335500.0)) + assert ok is False and env["at"] == [] + + +# ── 2. 미확인 판정 + 지표 ───────────────────────────────────────────────────── +def test_unconfirmed_clause_includes_legacy_order_price_rows(): + sql = str(fs.unconfirmed_fill_clause_v2().compile(dialect=postgresql.dialect())).lower() + assert "trade_history.executed_price is null" in sql + assert "trade_history.slippage is null" in sql + assert "trade_history.executed_price = trade_history.order_price" in sql + + +def test_sweep_query_uses_unconfirmed_clause(monkeypatch, env): + _patch_exec(monkeypatch, _FILLED) + db = _FakeDB([]) + _run(fs.sweep_unconfirmed_fills_v2(db)) + sql = str(db.queries[0].compile(dialect=postgresql.dialect())).lower() + assert "trade_history.slippage is null" in sql + assert "trade_history.executed_price = trade_history.order_price" in sql + + +def test_sweep_legacy_sell_row_recomputed(monkeypatch, env): + # 레거시: 스윙 SELL 이 체결가=주문가로 기록(slippage NULL) — 체결가로 손익 재계산 + row = _th_row(id=440, order_type="SELL", order_quantity=10, order_price=Decimal("11000"), + total_amount=Decimal("110000"), net_profit_loss=Decimal("10000"), + commission=None, entry_reason=None) + _patch_exec(monkeypatch, {"ok": True, "found": True, "filled_qty": 10, + "filled_price": 10900.0, "remaining_qty": 0}) + assert _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([row]))) == 1 + (tid, f), = env["th"] + assert tid == 440 and f["executed_price"] == Decimal("10900.0") + assert f["slippage"] is not None + assert float(f["profit_loss_rate"]) == pytest.approx(9.0) + h = fs.get_fill_sweep_health_v2() + assert h["candidates"] == 1 and h["queued"] == 1 + + +def test_exhausted_metric_and_warning_throttled(monkeypatch, env, caplog): + _patch_exec(monkeypatch, {"ok": False}) + monkeypatch.setattr(fs, "FILL_SWEEP_RETRY_INTERVAL_SEC", 0) + for _ in range(fs.FILL_SWEEP_MAX_ATTEMPTS): + _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([_th_row()]))) + with caplog.at_level(logging.WARNING, logger=fs.logger.name): + for _ in range(3): + _run(fs.sweep_unconfirmed_fills_v2(_FakeDB([]))) + assert fs.get_fill_sweep_health_v2()["exhausted"] == 1 + msgs = [r for r in caplog.records if "시도 소진" in r.getMessage()] + assert len(msgs) == 1 diff --git a/backend/tests/test_generic_rule_dispatch_r1.py b/backend/tests/test_generic_rule_dispatch_r1.py new file mode 100644 index 0000000..67a7674 --- /dev/null +++ b/backend/tests/test_generic_rule_dispatch_r1.py @@ -0,0 +1,51 @@ +"""하드닝 R1: realtime 규칙 디스패치 — SELL 규칙·차단 신호 종목이 매수 후보가 되지 않는다.""" +from types import SimpleNamespace as NS + +from app.api.endpoints.recommendations import _generic_rule_matches_v2 + + +def _rule(t): + return NS(rule_type=t) + + +def test_sell_rule_never_buy_candidate(): + s = NS(rsi=10, signals=[]) + assert _generic_rule_matches_v2(_rule("SELL"), s, {}) is False + + +def test_blocked_signal_excluded(): + s = NS(rsi=10, signals=["RSI 과매도 (거래량부족-차단)"]) + assert _generic_rule_matches_v2(_rule("BUY"), s, {}) is False + + +def test_buy_rule_keeps_existing_threshold(): + ok = NS(rsi=40, signals=[]) + no = NS(rsi=41, signals=[]) + assert _generic_rule_matches_v2(_rule("BUY"), ok, {}) is True + assert _generic_rule_matches_v2(_rule("BUY"), no, {}) is False + assert _generic_rule_matches_v2(_rule("BUY"), no, {"rsi_threshold": 45}) is True + + +def test_rsi_none_false(): + assert _generic_rule_matches_v2(_rule("BUY"), NS(rsi=None, signals=[]), {}) is False + + +def test_rsi_below_key_is_honored_strictly(): + r = _rule("BUY") + cond = {"rsi_below": 30} + assert _generic_rule_matches_v2(r, NS(rsi=29.9, signals=[]), cond) is True + assert _generic_rule_matches_v2(r, NS(rsi=30, signals=[]), cond) is False + assert _generic_rule_matches_v2(r, NS(rsi=35, signals=[]), cond) is False + + +def test_volume_condition_enforced_and_fail_closed(): + r = _rule("BUY") + cond = {"rsi_below": 30, "volume_above_average": 1.5} + assert _generic_rule_matches_v2(r, NS(rsi=20, signals=[], volume_ratio=1.6), cond) is True + assert _generic_rule_matches_v2(r, NS(rsi=20, signals=[], volume_ratio=1.2), cond) is False + # 거래량 배수 미노출 -> 통과 금지 + assert _generic_rule_matches_v2(r, NS(rsi=20, signals=[]), cond) is False + + +def test_unknown_condition_keys_fail_closed(): + assert _generic_rule_matches_v2(_rule("BUY"), NS(rsi=10, signals=[]), {"foo": 1}) is False diff --git a/backend/tests/test_guard_bypass_endpoints_20261002.py b/backend/tests/test_guard_bypass_endpoints_20261002.py index 080c64c..3b77f8a 100644 --- a/backend/tests/test_guard_bypass_endpoints_20261002.py +++ b/backend/tests/test_guard_bypass_endpoints_20261002.py @@ -278,10 +278,15 @@ def manual_buy_env(monkeypatch): async def _noop(*a, **k): return None monkeypatch.setattr(te, "_delayed_sync_portfolio", _noop) + verifies = [] + + async def _verify(**kw): + verifies.append(kw) + monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _verify) monkeypatch.setattr(dbw, "enqueue_trade_history", _noop) monkeypatch.setattr(dbw, "enqueue_portfolio_upsert", _noop) monkeypatch.setattr(sbr, "note_system_buy", lambda *a, **k: None) - return SimpleNamespace(mb=mb, total=total, cash=cash, px=px, orders=orders, ad=ad, k=k, cf=cf) + return SimpleNamespace(mb=mb, total=total, cash=cash, px=px, orders=orders, verifies=verifies, ad=ad, k=k, cf=cf) @pytest.mark.asyncio @@ -522,3 +527,123 @@ async def test_low_delete_pending_only_before_today(): assert out["deleted_count"] == 0 sql = str(db.stmts[0].compile(compile_kwargs={"literal_binds": False})) assert "created_at <" in sql, "당일 이전 조건이 없다 — 당일 PENDING(일일 한도 근거)까지 삭제" + + +@pytest.mark.asyncio +async def test_r2_swing_manual_sell_loss_is_net_of_costs(swing_sell_env, monkeypatch): + """왕복 비용 이내 소폭 이익 수동 청산은 패(순손익 기준, §5.1), TradeHistory 는 순손익 저장.""" + import app.services.db_worker as dbw + e = swing_sell_env + hist = [] + + async def _h(d): + hist.append(d) + monkeypatch.setattr(dbw, "enqueue_trade_history", _h) + # 현재가 10000, 진입가 settings 비용(약 0.21%)보다 작은 0.05% 이익 + entry = 10000 / 1.0005 + at = SimpleNamespace(entry_price=entry) + monkeypatch.setattr("app.services.swing.position_manager.get_swing_active_trade", + AsyncMock(return_value=at)) + monkeypatch.setattr("app.services.swing.exit_service._prior_position_realized_net_v2", + AsyncMock(return_value=None)) + holding = SimpleNamespace(quantity=10, stock_name="삼성전자", average_price=entry, strategy_type="swing") + from app.services.swing.exit_service import _swing_net_pnl_v2 + await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930"), db=_FakeDB([holding])) + expected = _swing_net_pnl_v2(entry, 10000, 6, "KOSPI")["net_profit_loss"] + assert expected < 0 < (10000 - entry) * 6, "전제: gross 이익 / net 손실" + assert hist[0]["net_profit_loss"] == pytest.approx(expected) + assert e.rec.await_args.kwargs["is_loss"] is True + + +@pytest.mark.asyncio +async def test_r2_swing_manual_sell_prior_leg_profit_makes_win(swing_sell_env, monkeypatch): + """앞 레그 실현이익이 마지막 소폭 손실 레그를 상쇄하면 승.""" + e = swing_sell_env + entry = 10100.0 # 현재가 10000 → gross 손실 + monkeypatch.setattr("app.services.swing.position_manager.get_swing_active_trade", + AsyncMock(return_value=SimpleNamespace(entry_price=entry))) + monkeypatch.setattr("app.services.swing.exit_service._prior_position_realized_net_v2", + AsyncMock(return_value=100000.0)) + holding = SimpleNamespace(quantity=10, stock_name="삼성전자", average_price=entry, strategy_type="swing") + await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930"), db=_FakeDB([holding])) + assert e.rec.await_args.kwargs["is_loss"] is False + + +# ── R3 2026-10-09: 수동 매수 결과 불명 / 지정가 PENDING ────────────────────── +@pytest.fixture +def mb_capture(manual_buy_env, monkeypatch): + import app.services.db_worker as dbw + import app.services.trade_executor as te + hist, ports = [], [] + + async def _h(d): + hist.append(d) + + async def _p(d): + ports.append(d) + monkeypatch.setattr(dbw, "enqueue_trade_history", _h) + monkeypatch.setattr(dbw, "enqueue_portfolio_upsert", _p) + e = manual_buy_env + e.mb._last_done.clear() + e.hist, e.ports, e.te = hist, ports, te + monkeypatch.setattr(e.mb, "spawn_tracked_v2", lambda c: c.close()) + return e + + +@pytest.mark.asyncio +async def test_r3_manual_buy_transport_unknown_sets_cooldown(mb_capture, monkeypatch): + e = mb_capture + + async def _unk(**kw): + return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "timeout"} + monkeypatch.setattr(e.te, "safe_place_order", _unk) + with pytest.raises(HTTPException) as ei: + await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []])) + assert ei.value.status_code == 504 + assert "005930" in e.mb._last_done + assert e.hist and e.hist[0]["status"] == "PENDING" and not e.ports + + +@pytest.mark.asyncio +async def test_r3_manual_buy_presend_failure_no_cooldown(mb_capture, monkeypatch): + e = mb_capture + + async def _pre(**kw): + return {"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT", "msg1": "x"} + monkeypatch.setattr(e.te, "safe_place_order", _pre) + with pytest.raises(HTTPException) as ei: + await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []])) + assert ei.value.status_code == 500 + assert "005930" not in e.mb._last_done and not e.hist + + +@pytest.mark.asyncio +async def test_r3_manual_buy_limit_order_pending_no_portfolio_row(mb_capture): + e = mb_capture + px = int(e.px * 0.9) + await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930", price=px), "005930", _FakeDB([None, 0, []])) + assert e.hist[0]["status"] == "PENDING" and e.hist[0]["executed_quantity"] == 0 + assert not e.ports + + +@pytest.mark.asyncio +async def test_r3_manual_buy_market_order_unchanged(mb_capture): + e = mb_capture + await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []])) + assert e.hist[0]["status"] == "COMPLETED" and e.hist[0]["executed_quantity"] == e.orders[0] + assert len(e.ports) == 1 + + +@pytest.mark.asyncio +async def test_r7_manual_limit_buy_spawns_fill_verification(manual_buy_env): + """지정가 수동 매수의 PENDING 행을 확정할 체결 확인 태스크가 ODNO 로 연결돼야 한다.""" + import asyncio + e = manual_buy_env + req = e.mb.BuyRequest(stock_code="005930", price=int(e.px)) + await e.mb._buy_stock_impl(req, "005930", _FakeDB([None, 0, []])) + await asyncio.sleep(0) + await asyncio.sleep(0) + assert len(e.verifies) == 1 + v = e.verifies[0] + assert v["order_id"] == "1" and v["order_type"] == "BUY" and v["is_domestic"] is True + assert v["sell_pnl_basis"] == {"strategy_type": "manual"} diff --git a/backend/tests/test_health_watch_script.py b/backend/tests/test_health_watch_script.py new file mode 100644 index 0000000..aea02ef --- /dev/null +++ b/backend/tests/test_health_watch_script.py @@ -0,0 +1,41 @@ +import importlib.util +import pathlib + +_p = pathlib.Path(__file__).resolve().parents[2] / "scripts" / "health_watch.py" +_spec = importlib.util.spec_from_file_location("health_watch", _p) +hw = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(hw) + +_OK = {"status": "healthy", "schedulers": {"swing": {"alive": True, "stale": False}}, + "db_worker": {"alive": True}, "dlq": {"manual": 0}} + + +def test_healthy_has_no_problems(): + assert hw.problems_v1(_OK) == [] + + +def test_dead_scheduler_detected_even_if_http_200(): + h = {**_OK, "status": "degraded", "schedulers": {"swing": {"alive": False, "stale": False}}} + p = hw.problems_v1(h) + assert any("swing" in x for x in p) + + +def test_stale_and_dbworker_and_dlq(): + h = {**_OK, "schedulers": {"dt": {"alive": True, "stale": True, "last_cycle_age": 900}}, + "db_worker": {"alive": False}, "dlq": {"manual": 2}} + assert len(hw.problems_v1(h)) == 3 + + +def test_non_dict_is_problem(): + assert hw.problems_v1("") != [] + + +def test_alert_after_threshold_once_then_recover(): + s, a = hw.next_state_v1({}, ["x"], 2) + assert a is None + s, a = hw.next_state_v1(s, ["x"], 2) + assert a == "alert" + s, a = hw.next_state_v1(s, ["x"], 2) + assert a is None + s, a = hw.next_state_v1(s, [], 2) + assert a == "recover" and s == {"fails": 0, "alerted": False} diff --git a/backend/tests/test_infra_security_round2_20261002.py b/backend/tests/test_infra_security_round2_20261002.py index 228292d..ef884dc 100644 --- a/backend/tests/test_infra_security_round2_20261002.py +++ b/backend/tests/test_infra_security_round2_20261002.py @@ -1691,3 +1691,100 @@ async def test_query_5xx_with_json_behavior_unchanged(tmp_path): with pytest.raises(Exception, match="최대 재시도 초과"): await api._make_request_with_retry("GET", "/uapi/domestic-stock/v1/quotations/inquire-price", "TR") assert len(client.auth_headers) == api.max_retries + 1 + + +def test_compose_backend_has_healthcheck_hang_only(): + """[R2 M] backend 는 무응답(hang) 감지 healthcheck 를 가져야 하고, degraded 로 오탐하지 않는다.""" + from pathlib import Path + + import yaml + + compose = yaml.safe_load((Path(__file__).resolve().parents[2] / "docker-compose.yml").read_text()) + hc = compose["services"]["backend"].get("healthcheck") + assert hc, "backend healthcheck 누락" + cmd = " ".join(hc["test"]) + assert "/health" in cmd and "timeout=" in cmd + assert "degraded" not in cmd and "healthy" not in cmd # 상태 문자열 판정 금지(오탐) + assert int(str(hc["retries"])) >= 2 + + +def test_compose_has_health_watch_sidecar(): + """[하드닝 R1] /health degraded 능동 감시는 NAS cron 에 의존하지 않고 compose 사이드카가 수행한다.""" + from pathlib import Path + + import yaml + + compose = yaml.safe_load((Path(__file__).resolve().parents[2] / "docker-compose.yml").read_text()) + svc = compose["services"]["health-watch"] + assert "health_watch_state" in compose["volumes"] + env = " ".join(svc["environment"]) + assert "HEALTH_URL=http://backend:8000/health" in env + assert "TELEGRAM_BOT_TOKEN" in env and "TELEGRAM_CHAT_ID" in env + assert any("health_watch.py" in v for v in svc["volumes"]) + assert "health_watch.py" in " ".join(svc["command"]) + assert svc["restart"] == "unless-stopped" + + +# ───────────────────────────────────────────────────────────────────────────── +# 라운드4 — Redis 초기 연결 실패 후 지연 재연결 +# ───────────────────────────────────────────────────────────────────────────── +def _load_redis_isolated(): + import importlib.util + from pathlib import Path + + spec = importlib.util.spec_from_file_location( + "_redis_lazy_under_test", Path(__file__).resolve().parents[1] / "app" / "core" / "redis.py" + ) + r = importlib.util.module_from_spec(spec) + spec.loader.exec_module(r) + return r + + +@pytest.mark.asyncio +async def test_redis_lazy_reconnect_after_init_failure(monkeypatch): + r = _load_redis_isolated() + built = [] + + class _Dead: + async def ping(self): + raise ConnectionError("down") + + class _Alive: + pass + + def _from_url(url, **kw): + c = _Dead() if not built else _Alive() + built.append(c) + return c + + monkeypatch.setattr(r.aioredis, "from_url", _from_url) + monkeypatch.setattr(r, "_redis_client", None) + clock = [1000.0] + monkeypatch.setattr(r.time, "monotonic", lambda: clock[0]) + + await r.init_redis() + assert r._redis_client is None + # 쓰로틀 구간 내에는 재시도하지 않는다 + assert r.get_redis() is None + assert len(built) == 1 + clock[0] += r.REDIS_RECONNECT_INTERVAL_SEC + 1 + c = r.get_redis() + assert isinstance(c, _Alive) + assert r.get_redis() is c # 이후 재생성 없음 + assert len(built) == 2 + + +@pytest.mark.asyncio +async def test_redis_no_lazy_reconnect_after_close(monkeypatch): + r = _load_redis_isolated() + + def _boom(*a, **k): + raise AssertionError("재연결 금지") + + monkeypatch.setattr(r.aioredis, "from_url", _boom) + monkeypatch.setattr(r, "_redis_client", None) + clock = [1000.0] + monkeypatch.setattr(r.time, "monotonic", lambda: clock[0]) + await r.close_redis() + clock[0] += 1000 + assert r.get_redis() is None diff --git a/backend/tests/test_journal_intraday_20261001.py b/backend/tests/test_journal_intraday_20261001.py index db9caf4..e934127 100644 --- a/backend/tests/test_journal_intraday_20261001.py +++ b/backend/tests/test_journal_intraday_20261001.py @@ -92,7 +92,7 @@ async def test_enqueue_failure_is_not_marked_and_retried(monkeypatch): def test_intraday_scheduler_wired_and_config_driven(): main_src = (APP_ROOT / "main.py").read_text() - assert "journal_intraday_scheduler()" in main_src + assert "journal_intraday_scheduler" in main_src assert '"journal_intraday_task"' in main_src src = (APP_ROOT / "services" / "schedulers.py").read_text() body = src[src.index("async def journal_intraday_scheduler"):src.index("async def post_market_analyzer_scheduler")] diff --git a/backend/tests/test_kis_breaker_alert_task_ref_r6.py b/backend/tests/test_kis_breaker_alert_task_ref_r6.py new file mode 100644 index 0000000..9553424 --- /dev/null +++ b/backend/tests/test_kis_breaker_alert_task_ref_r6.py @@ -0,0 +1,38 @@ +"""R6: 서킷브레이커 OPEN 경보 태스크 참조 보관.""" +import asyncio +import pytest +from app.services import korea_investment_api as kia + + +@pytest.mark.asyncio +async def test_alert_task_held_and_released(monkeypatch): + from app.services.telegram_notifier import telegram_notifier + gate = asyncio.Event() + + async def fake(*a, **k): + await gate.wait() + + monkeypatch.setattr(telegram_notifier, "notify_system_warning", fake) + kia._breaker_alert_tasks.clear() + cb = kia.CircuitBreaker(failure_threshold=1, recovery_timeout=1) + kia._alert_breaker_open_v2(cb) + assert len(kia._breaker_alert_tasks) == 1 + gate.set() + await asyncio.sleep(0.05) + assert len(kia._breaker_alert_tasks) == 0 + + +@pytest.mark.asyncio +async def test_alert_task_exception_logged_not_raised(monkeypatch, caplog): + from app.services.telegram_notifier import telegram_notifier + + async def boom(*a, **k): + raise RuntimeError("tg down") + + monkeypatch.setattr(telegram_notifier, "notify_system_warning", boom) + kia._breaker_alert_tasks.clear() + cb = kia.CircuitBreaker(failure_threshold=1, recovery_timeout=1) + kia._alert_breaker_open_v2(cb) + await asyncio.sleep(0.05) + assert not kia._breaker_alert_tasks + assert "경보 태스크 실패" in caplog.text diff --git a/backend/tests/test_kis_breaker_orders_r4.py b/backend/tests/test_kis_breaker_orders_r4.py new file mode 100644 index 0000000..92df3b4 --- /dev/null +++ b/backend/tests/test_kis_breaker_orders_r4.py @@ -0,0 +1,98 @@ +"""R4: KIS 서킷브레이커는 주문(청산 포함)을 차단하지 않고, 주문 실패는 조회 breaker 에 세지 않는다.""" +import asyncio +import pytest + +from app.services import korea_investment_api as kia +from app.services.utils import CircuitBreakerState + +ORDER_EP = "/uapi/domestic-stock/v1/trading/order-cash" +QUOTE_EP = "/uapi/domestic-stock/v1/quotations/inquire-price" + + +class _Resp: + def __init__(self, status=200, body=None): + self.status_code, self._b, self.headers, self.text = status, body or {}, {}, "" + + def json(self): + return self._b + + +class _Client: + def __init__(self, resp): + self.resp, self.calls = resp, 0 + + async def request(self, *a, **k): + self.calls += 1 + return self.resp + + +def _api(monkeypatch, resp): + api = kia.KoreaInvestmentAPI.__new__(kia.KoreaInvestmentAPI) + api.circuit_breaker = kia.CircuitBreaker(failure_threshold=2, recovery_timeout=120) + api.monitor = kia.APICallMonitor() + api.semaphore = asyncio.Semaphore(2) + api._throttle_lock = asyncio.Lock() + api.last_request_time = None + api.min_request_interval = 0 + api.max_retries = 0 + api.retry_delay = 0 + api.retry_backoff = 1 + api.timeout = 1 + api.app_key = api.app_secret = "x" + api.base_url = "http://x" + client = _Client(resp) + + async def _tok(*a, **k): + return "tok" + + async def _cl(): + return client + monkeypatch.setattr(api, "_get_access_token", _tok, raising=False) + monkeypatch.setattr(api, "_get_http_client", _cl, raising=False) + return api, client + + +def _open(api): + for _ in range(api.circuit_breaker.failure_threshold): + api.circuit_breaker.record_failure() + assert api.circuit_breaker.state == CircuitBreakerState.OPEN + + +@pytest.mark.asyncio +async def test_order_not_blocked_when_breaker_open(monkeypatch): + api, client = _api(monkeypatch, _Resp(200, {"rt_cd": "0"})) + _open(api) + out, _ = await api._make_request("POST", ORDER_EP, "TTTC0801U") + assert out["rt_cd"] == "0" and client.calls == 1 + with pytest.raises(Exception, match="Circuit breaker open"): + await api._make_request("GET", QUOTE_EP, "X") + assert client.calls == 1 + + +@pytest.mark.asyncio +async def test_order_failures_do_not_trip_breaker(monkeypatch): + api, _ = _api(monkeypatch, _Resp(500, {"rt_cd": "1", "msg_cd": "EGW00201"})) + for _ in range(api.circuit_breaker.failure_threshold + 3): + await api._make_request("POST", ORDER_EP, "TTTC0801U") + assert api.circuit_breaker.state == CircuitBreakerState.CLOSED + assert api.circuit_breaker.failure_count == 0 + + +@pytest.mark.asyncio +async def test_query_failures_still_trip_and_alert(monkeypatch): + sent = [] + from app.services import telegram_notifier as tn + + async def _warn(cat, msg): + sent.append(cat) + monkeypatch.setattr(tn.telegram_notifier, "notify_system_warning", _warn) + api, _ = _api(monkeypatch, _Resp(500, {})) + for _ in range(api.circuit_breaker.failure_threshold): + with pytest.raises(Exception): + await api._make_request("GET", QUOTE_EP, "X") + await asyncio.sleep(0) + assert api.circuit_breaker.state == CircuitBreakerState.OPEN + assert api.circuit_breaker.open_count == 1 + assert sent == ["KIS_CIRCUIT_BREAKER"] + assert api.circuit_breaker.health()["open_count"] == 1 + assert "korea_investment_api" in kia.get_kis_breaker_health_v2() diff --git a/backend/tests/test_kr_backtest_adjust_regime_20261009.py b/backend/tests/test_kr_backtest_adjust_regime_20261009.py new file mode 100644 index 0000000..b1959f5 --- /dev/null +++ b/backend/tests/test_kr_backtest_adjust_regime_20261009.py @@ -0,0 +1,63 @@ +"""kr_backtest_engine: 원주가 불연속 역보정 + regime='AUTO' 거부.""" +import asyncio +from datetime import date, timedelta + +import pytest + +from app.core.config import settings +from app.services.kr_backtest_engine import ( + BarData, KrBacktestEngine, MeanReversionBacktest, back_adjust_price_events_v2, +) + +D0 = date(2026, 1, 5) + + +def _b(i, px, vol=1000): + return BarData(trade_date=D0 + timedelta(days=i), open_price=px, high_price=px, + low_price=px, close_price=px, volume=vol, stock_code="A") + + +def test_split_back_adjusted(): + bars = [_b(0, 1000), _b(1, 1000), _b(2, 500), _b(3, 505)] + n = back_adjust_price_events_v2(bars, [None, 0.0, 0.0, 0.01]) + assert n == 1 + assert [b.close_price for b in bars] == [500, 500, 500, 505] + assert bars[0].volume == 2000 and bars[2].volume == 1000 + + +def test_normal_move_untouched_and_no_rate_skipped(): + bars = [_b(0, 1000), _b(1, 900)] + assert back_adjust_price_events_v2(bars, [None, -0.10]) == 0 + assert back_adjust_price_events_v2([_b(0, 1000), _b(1, 400)], [None, None]) == 0 + assert bars[0].close_price == 1000 + + +def test_genuine_crash_not_adjusted(): + bars = [_b(0, 1000), _b(1, 700)] + assert back_adjust_price_events_v2(bars, [None, -0.30]) == 0 + + +def test_gap_not_event(): + bars = [_b(0, 1000), _b(10, 400)] + assert back_adjust_price_events_v2(bars, [None, 0.0]) == 0 + + +def test_auto_regime_rejected(): + with pytest.raises(ValueError): + MeanReversionBacktest._get_regime({"regime": "auto"}) + with pytest.raises(ValueError): + MeanReversionBacktest._get_regime({"regime": "???"}) + assert MeanReversionBacktest._get_regime({"regime": "bear"}).name == "BEAR" + + +def test_run_rejects_auto_before_loading(monkeypatch): + async def boom(*a, **k): + raise AssertionError("데이터 로드까지 가면 안 됨") + monkeypatch.setattr("app.services.kr_backtest_engine.load_daily_bars", boom) + with pytest.raises(ValueError): + asyncio.run(KrBacktestEngine().run(["A"], D0, D0, params={"regime": "AUTO"})) + + +def test_default_regime_valid(): + assert MeanReversionBacktest().default_params()["regime"] == "BULL" + assert settings.MAX_POSITION_SIZE > 0 diff --git a/backend/tests/test_kr_backtest_exit_v2_20261009.py b/backend/tests/test_kr_backtest_exit_v2_20261009.py new file mode 100644 index 0000000..aa10c25 --- /dev/null +++ b/backend/tests/test_kr_backtest_exit_v2_20261009.py @@ -0,0 +1,86 @@ +"""kr_backtest_engine 청산: 라이브 모드/DB 비의존 + 실전 분할익절 규약.""" +from datetime import date + +import pytest + +from app.core.config import settings +from app.services import kr_backtest_engine as eng +from app.services.kr_backtest_engine import ( + BarData, + MeanReversionBacktest, + Position, + check_exit_signal_backtest_v2, +) +from app.services.sizing_core import remaining_pct_after_partial_sell + + +@pytest.fixture(autouse=True) +def _no_live_state(monkeypatch): + """라이브 적응형·DB 값 조회는 호출 즉시 예외 (거짓 통과 방지).""" + def boom(*a, **k): + raise AssertionError("백테스트 청산이 라이브 상태를 읽음") + import app.services.adaptive_strategy as adp + import app.services.mean_reversion_strategy as mr + monkeypatch.setattr(adp, "get_current_adaptive_params", boom) + monkeypatch.setattr(mr, "resolve_swing_stop_params_v2", boom) + monkeypatch.setattr(eng, "check_exit_signal", boom) + + +def _p(**kw): + return {**MeanReversionBacktest().default_params(), **kw} + + +def test_stop_uses_params(): + p = _p(stop_loss_pct=3.0, atr_stop_multiplier=settings.SWING_ATR_STOP_MULT_MAX) + sig = check_exit_signal_backtest_v2(p, 50, 9690, 20000, 20000, 10000, 100, 1.0, None) + assert sig and sig.sell_pct == 1.0 and "손절" in sig.reason + assert check_exit_signal_backtest_v2(p, 50, 9750, 20000, 20000, 10000, 100, 1.0, None) is None + + +def test_positive_stop_sign_guard(): + sig = check_exit_signal_backtest_v2(_p(stop_loss_pct=7.0), 50, 10100, 20000, 20000, 10000, 100, 1.0, None) + assert sig is None # 부호가 뒤집혀 +7% 로 해석되면 손절이 발동한다 + + +def test_rsi_partial_threshold_from_params(): + # close 10100 < bb_middle, 2xATR 미달 -> RSI 만 판정 + args = (41, 10100, 20000, 20000, 10000, 100, 1.0, None) + sig = check_exit_signal_backtest_v2(_p(rsi_exit_threshold=40), *args) + assert sig and sig.action == "SELL_PARTIAL" and sig.sell_pct == 0.5 + assert check_exit_signal_backtest_v2(_p(rsi_exit_threshold=60), *args) is None + + +def _bars(n, price): + return [BarData(trade_date=date(2024, 1, 1), open_price=price, high_price=price + 5, + low_price=price - 5, close_price=price, volume=1000, stock_code="A") + for _ in range(n)] + + +@pytest.fixture +def _ind(monkeypatch): + monkeypatch.setattr(eng, "calculate_rsi", lambda *a, **k: float(settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD) + 1) + monkeypatch.setattr(eng, "calculate_bollinger", lambda *a, **k: (20000.0, 20000.0, 8000.0)) + monkeypatch.setattr(eng, "calculate_atr", lambda *a, **k: 10.0) + + +def _pos(qty, orig, rem): + return Position("A", date(2024, 1, 1), 10000.0, qty, 10100.0, remaining_pct=rem, original_quantity=orig) + + +def test_partial_sell_qty_from_original_and_small_full(_ind): + st = MeanReversionBacktest() + bars = {"A": _bars(80, 10100)} + o = st.on_bar(70, bars, {"A": _pos(7, 7, 1.0)}, 0, 1e7, _p(max_positions=1)) + assert o and o[0]["quantity"] == 3 + # 단계 소진(0.5) 이후엔 1차 익절 재발동 없음 + assert st.on_bar(70, bars, {"A": _pos(4, 7, 0.5)}, 0, 1e7, _p(max_positions=1)) == [] + # 잔량 3주 이하 + 신호 -> 전량 + o = st.on_bar(70, bars, {"A": _pos(3, 7, 1.0)}, 0, 1e7, _p(max_positions=1)) + assert o[0]["quantity"] == 3 + + +def test_stage_remaining_pct_blocks_second_partial(): + r = remaining_pct_after_partial_sell(1.0, 0.5, 4, 7) + assert r <= 0.5 + args = (settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD + 1, 10100, 20000, 20000, 10000, 100, r, None) + assert check_exit_signal_backtest_v2(_p(), *args) is None diff --git a/backend/tests/test_kr_backtest_filler_20261009.py b/backend/tests/test_kr_backtest_filler_20261009.py new file mode 100644 index 0000000..37a59b6 --- /dev/null +++ b/backend/tests/test_kr_backtest_filler_20261009.py @@ -0,0 +1,93 @@ +"""kr_backtest_engine: 결측일 가짜 봉(is_filler)에서는 체결·신호·지표를 쓰지 않는다.""" +from datetime import date, timedelta + +from app.services import kr_backtest_engine as eng +from app.services.kr_backtest_engine import ( + BarData, BacktestStrategy, KrBacktestEngine, MeanReversionBacktest, + _align_bars_to_dates, +) + +D0 = date(2026, 1, 5) + + +def _dates(n): + return [D0 + timedelta(days=i) for i in range(n)] + + +def _bar(d, px, vol=1000, code="A"): + return BarData(trade_date=d, open_price=px, high_price=px + 1, low_price=px - 1, + close_price=px, volume=vol, stock_code=code) + + +def test_align_marks_filler_and_keeps_real(): + ds = _dates(4) + out = _align_bars_to_dates({"A": [_bar(ds[0], 100), _bar(ds[3], 90)]}, ds)["A"] + assert [b.is_filler for b in out] == [False, True, True, False] + assert out[1].volume == 0 + + +class _ScriptedStrategy(BacktestStrategy): + """지정한 bar_idx 에서 주문을 낸다.""" + def __init__(self, script): + self.script = script + + def name(self): return "s" + def description(self): return "s" + def default_params(self): return {} + + def on_bar(self, bar_idx, bars, positions, cash, total_equity, params): + return list(self.script.get(bar_idx, [])) + + +def _buy(q=10): + return {"action": "BUY", "stock_code": "A", "price": 100, "quantity": q, "reason": "t", "sell_pct": 0.0} + + +def _sell(q=10): + return {"action": "SELL", "stock_code": "A", "price": 100, "quantity": q, "reason": "t", "sell_pct": 1.0} + + +def _run(bars, ds, script): + st = _ScriptedStrategy(script) + return KrBacktestEngine()._simulate({"A": bars}, ds, st, {}, 1_000_000, "KOSPI", 1) + + +def test_sell_not_filled_on_filler_bar_carries_to_next_real_bar(): + ds = _dates(5) + # 0,1:실 2,3:가짜(정지) 4:실(갭하락 50) + aligned = _align_bars_to_dates({"A": [_bar(ds[0], 100), _bar(ds[1], 100), _bar(ds[4], 50)]}, ds)["A"] + res = _run(aligned, ds, {0: [_buy()], 1: [_sell()]}) + sells = [t for t in res.trades if t["side"] == "SELL"] + assert len(sells) == 1 + # 정지일의 평평한 가격(100 근처)이 아니라 재개일 갭 시가(50 근처)에 체결 + assert sells[0]["exit_date"] == str(ds[4]) + assert sells[0]["exit_price"] < 60 + + +def test_buy_signal_on_filler_next_bar_is_dropped(): + ds = _dates(4) + aligned = _align_bars_to_dates({"A": [_bar(ds[0], 100), _bar(ds[1], 100), _bar(ds[3], 100)]}, ds)["A"] + res = _run(aligned, ds, {1: [_buy()]}) # ds[2] 는 가짜 봉 + assert [t for t in res.trades if t["side"] == "BUY"] == [] + + +def test_mean_reversion_on_bar_ignores_filler_days(monkeypatch): + n = 70 + ds = _dates(n + 2) + real = [_bar(ds[i], 10000 + (i % 3)) for i in range(n)] + [_bar(ds[n + 1], 10000)] + aligned = _align_bars_to_dates({"A": real}, ds) + seen = [] + + def spy_rsi(closes, period=14): + seen.append(len(closes)) + return 50.0 + monkeypatch.setattr(eng, "calculate_rsi", spy_rsi) + monkeypatch.setattr(eng, "calculate_bollinger", lambda c, p=20: (110.0, 100.0, 90.0)) + monkeypatch.setattr(eng, "check_buy_signal_backtest_v2", lambda *a, **k: None) + s = MeanReversionBacktest() + p = s.default_params() + assert aligned["A"][n].is_filler + assert s.on_bar(n, aligned, {}, 1e6, 1e6, p) == [] + assert seen == [] # 가짜 봉 날에는 지표 계산 자체가 없다 + s.on_bar(n + 1, aligned, {}, 1e6, 1e6, p) + assert seen and seen[0] == n + 1 # 가짜 봉 1개가 입력에서 제외됨 diff --git a/backend/tests/test_kr_backtest_intraday_timestop_20261009.py b/backend/tests/test_kr_backtest_intraday_timestop_20261009.py new file mode 100644 index 0000000..e839c1f --- /dev/null +++ b/backend/tests/test_kr_backtest_intraday_timestop_20261009.py @@ -0,0 +1,168 @@ +"""kr_backtest_engine R4: 장중 저가 손절 + 스윙 시간손절 + 모멘텀 전용 청산. + +라이브 적응형/DB 조회는 호출 즉시 예외(거짓 통과 방지). 기대값은 settings 참조. +""" +from datetime import date + +import pytest + +from app.core.config import settings +from app.services import kr_backtest_engine as eng +from app.services.kr_backtest_engine import ( + BarData, + KrBacktestEngine, + MeanReversionBacktest, + Position, + check_momentum_exit_backtest_v2, + check_time_stop_backtest_v2, + intraday_stop_fill_v2, +) + + +@pytest.fixture(autouse=True) +def _no_live_state(monkeypatch): + def boom(*a, **k): + raise AssertionError("백테스트 청산이 라이브 상태를 읽음") + import app.services.adaptive_strategy as adp + import app.services.mean_reversion_strategy as mr + monkeypatch.setattr(adp, "get_current_adaptive_params", boom) + monkeypatch.setattr(mr, "resolve_swing_stop_params_v2", boom) + monkeypatch.setattr(eng, "check_exit_signal", boom) + monkeypatch.setattr(eng, "calculate_rsi", lambda *a, **k: float(settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD) + 1) + monkeypatch.setattr(eng, "calculate_bollinger", lambda *a, **k: (20000.0, 20000.0, 8000.0)) + monkeypatch.setattr(eng, "calculate_atr", lambda *a, **k: 10.0) + + +def _p(**kw): + return {**MeanReversionBacktest().default_params(), **kw} + + +def _bar(i, o, h, l, c): + return BarData(trade_date=date.fromordinal(738000 + i), open_price=o, high_price=h, + low_price=l, close_price=c, volume=1000, stock_code="A") + + +def _flat(n, price=10000): + return [_bar(i, price, price + 5, price - 5, price) for i in range(n)] + + +# ---- 순수 함수 ---- +def test_intraday_stop_fill_low_touch_and_gap(): + p = _p(atr_stop_multiplier=settings.ATR_STOP_MULTIPLIER) + # ATR=10 -> stop = max(10000-2.5*10, 10000*(1-pct)) = 9975 + assert intraday_stop_fill_v2(p, 10000, 10, 10000, 10000, 9980) is None + base, stop = intraday_stop_fill_v2(p, 10000, 10, 10000, 10000, 9970) + assert stop == pytest.approx(9975) and base == pytest.approx(9975) + base, stop = intraday_stop_fill_v2(p, 10000, 10, 10000, 9800, 9700) # 갭다운 -> 시가 + assert base == 9800 + + +def test_intraday_stop_sign_guard(): + # 양수 손절률이 들어와도 +7% 로 해석되면 안 된다 + p = _p(stop_loss_pct=abs(settings.STOP_LOSS_PERCENT), atr_stop_multiplier=settings.SWING_ATR_STOP_MULT_MAX) + assert intraday_stop_fill_v2(p, 10000, 10, 10000, 10000, 9990) is None + + +def test_time_stop_boundaries_use_settings(): + d = settings.SWING_TIME_STOP_DAYS + m = settings.SWING_TIME_STOP_MIN_PROFIT_PCT + assert check_time_stop_backtest_v2(_p(), d - 1, m - 1) is None + assert check_time_stop_backtest_v2(_p(), d, m + 0.1) is None + sig = check_time_stop_backtest_v2(_p(), d, m - 0.1) + assert sig and sig.sell_pct == 1.0 and "시간손절" in sig.reason + assert check_time_stop_backtest_v2(_p(swing_time_stop_enabled=False), d, 0.0) is None + + +def test_momentum_exit_stages(): + p = _p() + s1 = check_momentum_exit_backtest_v2(p, 11100, 10000, 10, 1.0, 11100) + assert s1 and s1.action == "SELL_PARTIAL" and s1.sell_pct == 0.30 + assert check_momentum_exit_backtest_v2(p, 11100, 10000, 10, 0.70, 11100) is None + s2 = check_momentum_exit_backtest_v2(p, 12100, 10000, 10, 0.70, 12100) + assert s2 and s2.sell_pct == 0.30 + stop = check_momentum_exit_backtest_v2(p, 9000, 10000, 10, 1.0, 10000) + assert stop and stop.sell_pct == 1.0 + trail = check_momentum_exit_backtest_v2(p, 11000, 10000, 100, 0.40, 11500) # 고점-1.5*ATR=11350 + assert trail and trail.sell_pct == 1.0 and "트레일링" in trail.reason + + +# ---- on_bar 통합: 시간손절 우선순위 / 모멘텀 분기 ---- +def _pos(**kw): + base = dict(stock_code="A", entry_date=date(2024, 1, 1), entry_price=10000.0, quantity=7, + highest_since_entry=10100.0, remaining_pct=1.0, original_quantity=7) + base.update(kw) + return Position(**base) + + +def test_time_stop_overrides_partial_signal(): + st = MeanReversionBacktest() + bars = {"A": _flat(80, 10100)} + d = settings.SWING_TIME_STOP_DAYS + o = st.on_bar(70, bars, {"A": _pos(entry_bar_idx=70 - d)}, 0, 1e7, _p(max_positions=1)) + assert o and o[0]["sell_pct"] == 1.0 and "시간손절" in o[0]["reason"] and o[0]["quantity"] == 7 + o = st.on_bar(70, bars, {"A": _pos(entry_bar_idx=70 - d + 1)}, 0, 1e7, _p(max_positions=1)) + assert o and o[0]["sell_pct"] == 0.5 + + +def test_momentum_position_skips_time_stop(): + st = MeanReversionBacktest() + bars = {"A": _flat(80, 10100)} + d = settings.SWING_TIME_STOP_DAYS + o = st.on_bar(70, bars, {"A": _pos(entry_bar_idx=70 - d, strategy="MOMENTUM_BREAKOUT")}, + 0, 1e7, _p(max_positions=1)) + assert o == [] + + +def test_momentum_position_uses_momentum_exit(): + st = MeanReversionBacktest() + bars = {"A": _flat(80, 11100)} + o = st.on_bar(70, bars, {"A": _pos(highest_since_entry=11100.0, strategy="MOMENTUM_BREAKOUT")}, + 0, 1e7, _p(max_positions=1)) + assert o and o[0]["sell_pct"] == 0.30 and "모멘텀" in o[0]["reason"] + + +# ---- 엔진 A-B: 장중 터치 후 종가 회복 ---- +class _BuyOnce(MeanReversionBacktest): + def on_bar(self, bar_idx, bars, positions, cash, total_equity, params): + if bar_idx == 64 and "A" not in positions: + return [{"action": "BUY", "stock_code": "A", "price": 10000.0, "quantity": 10, + "reason": "t", "sell_pct": 0.0}] + return [] + + +def _run(intraday, bars_list): + dates = [b.trade_date for b in bars_list] + params = _p(intraday_stop_enabled=intraday, stop_slippage_pct=0.0) + res = KrBacktestEngine()._simulate({"A": bars_list}, dates, _BuyOnce(), params, 10_000_000, "KOSPI", 1) + return [t for t in res.trades if t["side"] == "SELL"] + + +def _series(low_bar_open=10000, low=9900): + bars = _flat(70, 10000) + bars[67] = _bar(67, low_bar_open, 10005, low, 10000) # 장중 급락 후 종가 회복 + return bars + + +def test_engine_ab_intraday_stop_counts(): + on = _run(True, _series()) + off = _run(False, _series()) + assert len(on) == 1 and "장중손절" in on[0]["reason"] + assert len(off) == 0 + assert on[0]["exit_price"] == pytest.approx(on[0]["entry_price"] - 25.0) + + +def test_engine_gap_down_fills_at_open(): + on = _run(True, _series(low_bar_open=9700, low=9600)) + assert len(on) == 1 and on[0]["exit_price"] == pytest.approx(9700.0) + + +def test_no_lookahead_atr_uses_prev_bars(monkeypatch): + seen = [] + + def fake_atr(h, l, c, period=14): + seen.append(len(c)) + return 10.0 + monkeypatch.setattr(eng, "calculate_atr", fake_atr) + st = MeanReversionBacktest() + st.intraday_exits_v2(70, {"A": _flat(80)}, {"A": _pos()}, _p()) + assert seen == [70] diff --git a/backend/tests/test_loss_tracker_ban_preserved_20261009.py b/backend/tests/test_loss_tracker_ban_preserved_20261009.py new file mode 100644 index 0000000..eb918c9 --- /dev/null +++ b/backend/tests/test_loss_tracker_ban_preserved_20261009.py @@ -0,0 +1,48 @@ +"""이익 청산이 유효한 ban 을 해제하지 않는다 (loss_tracker 회귀).""" +from datetime import datetime, timedelta, timezone + +import pytest + +from app.core.config import settings +from app.services.loss_tracker import ConsecutiveLossTracker + + +def _banned(): + t = ConsecutiveLossTracker( + ban_count=settings.CONSECUTIVE_LOSS_BAN_COUNT, + ban_calendar_days=settings.CONSECUTIVE_LOSS_BAN_DAYS, + ) + for _ in range(settings.CONSECUTIVE_LOSS_BAN_COUNT): + t.record_trade_result("105560", True) + assert t.is_banned("105560") + return t + + +def test_profit_does_not_lift_active_ban(): + t = _banned() + before = t._tracker["105560"]["ban_until"] + t.record_trade_result("105560", False) + assert t.is_banned("105560") + assert t._tracker["105560"]["ban_until"] == before + + +@pytest.mark.asyncio +async def test_safe_path_profit_keeps_ban(): + t = _banned() + await t.record_trade_result_safe("105560", False) + assert await t.is_banned_safe("105560") + + +def test_profit_after_expiry_resets(): + t = _banned() + t._tracker["105560"]["ban_until"] = datetime.now(timezone.utc) - timedelta(seconds=1) + t.record_trade_result("105560", False) + assert t._tracker["105560"]["ban_until"] is None + assert t._tracker["105560"]["consecutive_losses"] == 0 + + +def test_profit_without_ban_resets_counter(): + t = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42) + t.record_trade_result("X", True) + t.record_trade_result("X", False) + assert t._tracker["X"]["consecutive_losses"] == 0 diff --git a/backend/tests/test_loss_tracker_load_failclosed_20261009.py b/backend/tests/test_loss_tracker_load_failclosed_20261009.py new file mode 100644 index 0000000..d62e162 --- /dev/null +++ b/backend/tests/test_loss_tracker_load_failclosed_20261009.py @@ -0,0 +1,147 @@ +"""loss_tracker 기동 복원 실패 fail-closed 회귀 (밴 소실 방지).""" +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +import app.services.loss_tracker as lt +from app.services.loss_tracker import ConsecutiveLossTracker + + +def _t(): + return ConsecutiveLossTracker( + ban_count=settings.CONSECUTIVE_LOSS_BAN_COUNT, + ban_calendar_days=settings.CONSECUTIVE_LOSS_BAN_DAYS, + ) + + +class _FailDB: + def __init__(self): + self.calls = 0 + self.rollbacks = 0 + + async def execute(self, *a, **k): + self.calls += 1 + raise ConnectionError("db down") + + async def rollback(self): + self.rollbacks += 1 + + +class _OkDB: + def __init__(self, recs): + self.recs = recs + + async def execute(self, *a, **k): + recs = self.recs + return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: recs)) + + async def rollback(self): + pass + + +@pytest.fixture(autouse=True) +def _fast(monkeypatch): + monkeypatch.setattr(lt, "_LOAD_RETRY_DELAYS", (0.0, 0.0)) + + +@pytest.mark.asyncio +async def test_load_failure_retries_and_fails_closed(): + t = _t() + db = _FailDB() + notifier = SimpleNamespace(notify_system_warning=AsyncMock()) + with patch("app.services.telegram_notifier.telegram_notifier", notifier): + await t.load_from_db(db) + assert db.calls == 3 and db.rollbacks == 3 + assert t._load_failed is True + assert t.is_banned("000270") # 아무 종목이나 차단 + notifier.notify_system_warning.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_save_deferred_while_failed_then_merged_on_success(): + t = _t() + t._load_failed = True + t.record_trade_result("000270", True) + enq = AsyncMock() + with patch("app.services.db_worker.enqueue_stock_ban_upsert", enq): + await t.save_to_db(None, "000270") + enq.assert_not_awaited() # DB 밴 행 덮어쓰기 금지 + + future = (datetime.now(timezone.utc) + timedelta(days=10)).replace(tzinfo=None) + rec = SimpleNamespace(stock_code="000270", consecutive_losses=settings.CONSECUTIVE_LOSS_BAN_COUNT, + ban_until=future) + await t.load_from_db(_OkDB([rec])) + assert t._load_failed is False + assert t.is_banned("000270") + assert t._tracker["000270"]["consecutive_losses"] >= settings.CONSECUTIVE_LOSS_BAN_COUNT + enq.assert_awaited_once() # 병합된 값이 저장됨 + + +@pytest.mark.asyncio +async def test_retry_skips_within_min_interval(monkeypatch): + t = _t() + t._load_failed = True + import time + t._last_load_attempt = time.monotonic() + assert await t.retry_load_if_failed_v2() is False + assert t._load_failed is True + + +def test_normal_tracker_unaffected(): + t = _t() + assert t.is_banned("005930") is False + + +@pytest.mark.asyncio +async def test_is_banned_safe_does_not_block_on_slow_reload(monkeypatch): + """진입 판정은 재복원을 기다리지 않고 즉시 fail-closed, 재복원은 단일 비행.""" + import asyncio + import app.core.database as dbmod + + started = [] + release = asyncio.Event() + + class _SlowSession: + async def __aenter__(self): + started.append(1) + await release.wait() # DB 무응답 + raise ConnectionError("db down") + + async def __aexit__(self, *a): + return False + + monkeypatch.setattr(dbmod, "AsyncSessionLocal", lambda: _SlowSession()) + monkeypatch.setattr(lt, "_LOAD_RETRY_MIN_INTERVAL_SEC", 0.0) + t = _t() + t._load_failed = True + r1 = await asyncio.wait_for(t.is_banned_safe("000270"), timeout=1.0) + r2 = await asyncio.wait_for(t.is_banned_safe("005930"), timeout=1.0) + assert r1 is True and r2 is True + await asyncio.sleep(0) + assert len(started) == 1 # 단일 비행 + release.set() + await t._reload_task + assert t._load_failed is True + + +@pytest.mark.asyncio +async def test_background_reload_has_timeout_cap(monkeypatch): + import asyncio + import app.core.database as dbmod + + class _HangSession: + async def __aenter__(self): + await asyncio.sleep(60) + + async def __aexit__(self, *a): + return False + + monkeypatch.setattr(dbmod, "AsyncSessionLocal", lambda: _HangSession()) + monkeypatch.setattr(lt, "_LOAD_RETRY_TASK_TIMEOUT_SEC", 0.05) + t = _t() + t._load_failed = True + await asyncio.wait_for(t._reload_in_background_impl(), timeout=2.0) + assert t._load_failed is True diff --git a/backend/tests/test_loss_tracker_retro_correction_20261010.py b/backend/tests/test_loss_tracker_retro_correction_20261010.py new file mode 100644 index 0000000..bc1b30d --- /dev/null +++ b/backend/tests/test_loss_tracker_retro_correction_20261010.py @@ -0,0 +1,62 @@ +"""시간손절 중립화 소급 보정(replay/plan/apply) 회귀.""" +from datetime import datetime, timedelta + +import pytest + +from app.core.config import settings +from app.services import loss_tracker as lt + +N = settings.CONSECUTIVE_LOSS_BAN_COUNT +D = settings.CONSECUTIVE_LOSS_BAN_DAYS +T0 = datetime(2026, 9, 30, 14, 57) + + +def _ev(i, net, ts=False): + return {"at": T0 + timedelta(days=i), "net": net, "time_stop": ts} + + +def test_105560_pattern_time_stops_excluded(): + evs = [_ev(0, -2100, True), _ev(1, -2100, True), _ev(6, 402)] + r = lt.replay_streak_v2(evs, N, D) + assert r == {"consecutive_losses": 0, "ban_until": None} + + +def test_real_losses_still_ban(): + evs = [_ev(i, -100) for i in range(N)] + r = lt.replay_streak_v2(evs, N, D) + assert r["consecutive_losses"] == N + assert r["ban_until"] == T0 + timedelta(days=N - 1 + D) + + +def test_plan_flags_lifted_ban_and_skips_unchanged(): + cur = { + "105560": {"consecutive_losses": N, "ban_until": T0 + timedelta(days=D)}, + "449450": {"consecutive_losses": 1, "ban_until": None}, + } + ev = { + "105560": [_ev(0, -2100, True), _ev(1, -2100, True), _ev(6, -650)], + "449450": [_ev(0, -410)], + } + plan = lt.build_ban_correction_plan_v2(ev, cur, N, D) + assert [p["stock_code"] for p in plan] == ["105560"] + assert plan[0]["ban_lifted"] is True + assert plan[0]["recomputed"]["consecutive_losses"] == 1 + + +@pytest.mark.asyncio +async def test_apply_writes_only_approved_via_queue(monkeypatch): + sent = [] + + async def fake(stock_code, consecutive_losses, ban_until): + sent.append((stock_code, consecutive_losses, ban_until)) + + import app.services.db_worker as dbw + monkeypatch.setattr(dbw, "enqueue_stock_ban_upsert", fake) + plan = [ + {"stock_code": "A", "current": {}, "recomputed": {"consecutive_losses": 0, "ban_until": None}, "ban_lifted": True}, + {"stock_code": "B", "current": {}, "recomputed": {"consecutive_losses": 1, "ban_until": None}, "ban_lifted": False}, + ] + assert await lt.apply_ban_correction_v2(plan, []) == [] + assert sent == [] + assert await lt.apply_ban_correction_v2(plan, ["A", "ZZZ"]) == ["A"] + assert sent == [("A", 0, None)] diff --git a/backend/tests/test_main_db_worker_restart_r2.py b/backend/tests/test_main_db_worker_restart_r2.py new file mode 100644 index 0000000..af70be6 --- /dev/null +++ b/backend/tests/test_main_db_worker_restart_r2.py @@ -0,0 +1,87 @@ +"""하드닝 R2: db_worker 크래시 재시작 백오프/한도, stock_analysis 보존기간.""" +import asyncio +import time +import types + +import pytest + +from app import main as m + + +@pytest.fixture(autouse=True) +def _clean(monkeypatch): + monkeypatch.setattr(m, "_db_worker_restarts", []) + monkeypatch.setattr(m, "_db_worker_restart_total", 0) + monkeypatch.setattr(m, "_db_worker_restart_limit_hit", False) + monkeypatch.setattr(m, "_db_worker_restart_handle", None) + monkeypatch.setattr(m, "_shutting_down", False) + monkeypatch.setattr(m, "_DB_WORKER_RESTART_BACKOFF_SEC", (0.0,)) + calls = [] + monkeypatch.setattr(m, "_notify_system_warning_bg", lambda c, msg: calls.append((c, msg))) + return calls + + +async def _crashed(): + async def boom(): + raise RuntimeError("boom") + t = asyncio.create_task(boom()) + await asyncio.sleep(0) + await asyncio.sleep(0) + return t + + +def test_backoff_plan_grows_and_limits(monkeypatch): + monkeypatch.setattr(m, "_DB_WORKER_RESTART_BACKOFF_SEC", (1.0, 5.0)) + assert m._db_worker_restart_plan_v2(1000.0) == (1.0, False) + m._db_worker_restarts.append(990.0) + assert m._db_worker_restart_plan_v2(1000.0) == (5.0, False) + m._db_worker_restarts[:] = [990.0 + i for i in range(m._DB_WORKER_MAX_RESTARTS_PER_HOUR)] + wait, hit = m._db_worker_restart_plan_v2(1000.0) + assert hit is True and wait >= m._DB_WORKER_LIMIT_COOLDOWN_SEC + + +@pytest.mark.asyncio +async def test_restart_is_deferred_not_immediate(_clean): + app = types.SimpleNamespace(state=types.SimpleNamespace()) + started = [] + + async def factory(): + started.append(1) + await asyncio.sleep(10) + + t = await _crashed() + m._on_db_worker_done_v2(app, t, factory) + assert started == [] # call_later 로 예약만, 즉시 재시작 아님 + assert m._db_worker_restart_handle is not None + assert _clean and _clean[0][0] == "DB_WORKER" + await asyncio.sleep(0.05) + assert started == [1] and m._db_worker_restart_total == 1 + app.state.db_worker_task.cancel() + + +@pytest.mark.asyncio +async def test_limit_hit_flagged_in_health(): + app = types.SimpleNamespace(state=types.SimpleNamespace()) + m._db_worker_restarts[:] = [time.monotonic()] * m._DB_WORKER_MAX_RESTARTS_PER_HOUR + + async def factory(): + await asyncio.sleep(10) + + t = await _crashed() + m._on_db_worker_done_v2(app, t, factory) + h = m._db_worker_health_v2(app) + assert h["restart_limit_hit"] is True + m._db_worker_restart_handle.cancel() + + +@pytest.mark.asyncio +async def test_no_restart_when_shutting_down(monkeypatch): + monkeypatch.setattr(m, "_shutting_down", True) + app = types.SimpleNamespace(state=types.SimpleNamespace()) + t = await _crashed() + m._on_db_worker_done_v2(app, t, lambda: None) + assert m._db_worker_restart_handle is None + + +def test_stock_analysis_retention_beyond_a_week(): + assert m._STOCK_ANALYSIS_RETENTION_DAYS >= 90 diff --git a/backend/tests/test_main_health_exposure_r4.py b/backend/tests/test_main_health_exposure_r4.py new file mode 100644 index 0000000..5d18ba0 --- /dev/null +++ b/backend/tests/test_main_health_exposure_r4.py @@ -0,0 +1,136 @@ +"""하드닝 R4: /health 가 스윙·MDD·적응형·체결보정 상태를 degraded 로 반영한다.""" +import pytest + +from app import main as m + + +class _NoDbEngine: + """실 DB 접속 금지 — 예외 발생형(§4.1). /health 는 DB 실패를 degraded 로 처리한다.""" + def connect(self): + raise RuntimeError("test: real DB access blocked") + + +@pytest.fixture(autouse=True) +def _block_db_and_redis(monkeypatch): + monkeypatch.setattr(m, "engine", _NoDbEngine()) + from app.core import redis as _redis + monkeypatch.setattr(_redis, "get_redis", lambda: None) + + +def _patch_getters(monkeypatch, swing=None, mdd=None, age=0.0, fs=None): + from app.services.swing import scheduler as sch, fill_sweep as fsw + from app.services import adaptive_strategy as adp + monkeypatch.setattr(sch, "get_swing_scheduler_health_v2", lambda: swing or {"degraded_reasons": []}) + monkeypatch.setattr(sch, "get_mdd_update_health", lambda: mdd or {"blind_block_active": False}) + monkeypatch.setattr(adp, "get_last_success_age_sec_v2", lambda: age) + monkeypatch.setattr(fsw, "get_fill_sweep_health_v2", lambda: fs or {"exhausted": 0}) + + +def test_clean(monkeypatch): + _patch_getters(monkeypatch) + _, reasons = m._runtime_health_v2(market_open=True) + assert reasons == [] + + +def test_swing_reasons_and_blind_block(monkeypatch): + _patch_getters(monkeypatch, swing={"degraded_reasons": ["stuck_steps", "exit_stale"]}, + mdd={"blind_block_active": True}) + _, reasons = m._runtime_health_v2(market_open=True) + assert {"stuck_steps", "exit_stale", "mdd_blind_block"} <= set(reasons) + + +def test_adaptive_stale_only_in_market(monkeypatch): + from app.services import adaptive_strategy as adp + _patch_getters(monkeypatch, age=adp._STALE_LIMIT_SEC + 1) + assert "adaptive_stale" in m._runtime_health_v2(market_open=True)[1] + assert "adaptive_stale" not in m._runtime_health_v2(market_open=False)[1] + + +def test_getter_exception_is_degraded_not_crash(monkeypatch): + from app.services.swing import scheduler as sch + + def boom(): + raise RuntimeError("x") + _patch_getters(monkeypatch) + monkeypatch.setattr(sch, "get_swing_scheduler_health_v2", boom) + _, reasons = m._runtime_health_v2(market_open=True) + assert "swing_health_unavailable" in reasons + + +@pytest.mark.asyncio +async def test_health_impl_degraded_on_dlq_manual_and_reasons(monkeypatch): + from app.services import db_worker as w + _patch_getters(monkeypatch, mdd={"blind_block_active": True}) + + async def dlq(): + return {"pending": 0, "manual": 2, "expired": 0} + monkeypatch.setattr(w, "dlq_status_counts", dlq) + monkeypatch.setattr(m, "is_market_open", lambda: True) + out = await m._health_check_impl() + assert out["status"] == "degraded" + assert "mdd_blind_block" in out["degraded_reasons"] and "dlq_manual" in out["degraded_reasons"] + + +def test_detail_endpoint_requires_api_key(): + route = [r for r in m.app.routes if getattr(r, "path", "") == "/health/detail"][0] + assert route.dependant.dependencies + + +@pytest.mark.asyncio +async def test_health_impl_never_touches_real_engine(monkeypatch): + """회귀: 실 engine 대신 차단 목이 쓰이고 db 실패는 degraded 로 귀결된다.""" + from app.services import db_worker as w + _patch_getters(monkeypatch) + + async def dlq(): + return {"pending": 0, "manual": 0, "expired": 0} + monkeypatch.setattr(w, "dlq_status_counts", dlq) + monkeypatch.setattr(m, "is_market_open", lambda: True) + out = await m._health_check_impl() + assert out["status"] == "degraded" + + +def _patch_extra(monkeypatch, wh=None, bh=None, kh=None, rh=None): + from app.services import db_worker as w, schedulers as sc, market_analyzer as ma + monkeypatch.setattr(w, "get_worker_health_v2", lambda: wh or {"qsize": 0, "circuit_open": False, "last_success_ts": None}) + monkeypatch.setattr(sc, "get_batch_analysis_health_v2", lambda: bh or {"consecutive_failed_cycles": 0}) + monkeypatch.setattr(sc, "get_krx_data_health_v2", lambda: kh or {"ok": None}) + monkeypatch.setattr(ma, "get_regime_source_health", lambda: rh or {"blocked": False}) + + +def test_db_circuit_and_write_stall(monkeypatch): + import time + _patch_getters(monkeypatch) + _patch_extra(monkeypatch, wh={"qsize": 3, "circuit_open": True, + "last_success_ts": time.time() - m._DB_WRITE_STALL_SEC - 10}) + _, reasons = m._runtime_health_v2(market_open=True) + assert {"db_circuit_open", "db_write_stalled"} <= set(reasons) + + +def test_idle_queue_not_stalled(monkeypatch): + import time + _patch_getters(monkeypatch) + _patch_extra(monkeypatch, wh={"qsize": 0, "circuit_open": False, + "last_success_ts": time.time() - 99999}) + assert m._runtime_health_v2(market_open=True)[1] == [] + + +def test_batch_krx_regime_reasons(monkeypatch): + _patch_getters(monkeypatch) + _patch_extra(monkeypatch, + bh={"consecutive_failed_cycles": m._BATCH_FAIL_CYCLES_DEGRADED}, + kh={"ok": False}, rh={"blocked": True}) + _, reasons = m._runtime_health_v2(market_open=True) + assert {"batch_analysis_failing", "krx_data_incomplete", "regime_blocked"} <= set(reasons) + assert "regime_blocked" not in m._runtime_health_v2(market_open=False)[1] + + +def test_new_getter_exception_degraded(monkeypatch): + from app.services import db_worker as w + _patch_getters(monkeypatch) + _patch_extra(monkeypatch) + + def boom(): + raise RuntimeError("x") + monkeypatch.setattr(w, "get_worker_health_v2", boom) + assert "db_worker_health_unavailable" in m._runtime_health_v2(market_open=True)[1] diff --git a/backend/tests/test_main_heartbeat_r1.py b/backend/tests/test_main_heartbeat_r1.py new file mode 100644 index 0000000..4f77746 --- /dev/null +++ b/backend/tests/test_main_heartbeat_r1.py @@ -0,0 +1,287 @@ +"""하드닝 R1: 스케줄러 heartbeat/health/done_callback 회귀 테스트.""" +import asyncio +import types + +import pytest + +from app import main as m +from app.core import heartbeat as hb + + +@pytest.fixture(autouse=True) +def _clean(monkeypatch): + hb.reset() + monkeypatch.setattr(m, "_scheduler_tasks", {}) + monkeypatch.setattr(m, "_scheduler_restarts", {}) + monkeypatch.setattr(m, "_shutting_down", False) + monkeypatch.setattr(m, "_restart_handles", {}) + monkeypatch.setattr(m, "_HB_SCHEDULER_RESTART_BACKOFF_SEC", (0.0,)) + # 텔레그램 실호출 차단: 예외 발생형이 아닌 기록형 대체(알림 경로 자체가 검증 대상) + calls = [] + monkeypatch.setattr(m, "_notify_system_warning_bg", lambda c, msg: calls.append((c, msg))) + yield calls + hb.reset() + + +def _app(): + return types.SimpleNamespace(state=types.SimpleNamespace()) + + +def test_stale_only_in_market_hours(): + app = _app() + + class T: + def done(self): + return False + app.state.swing_trade_task = T() + m._scheduler_tasks["swing"] = "swing_trade_task" + hb.beat("swing", "x") + far = hb._beats["swing"]["at"] + m._HB_SCHEDULER_STALE_SECONDS + 5 + assert m._scheduler_health_v2(app, now=far, market_open=True)["swing"]["stale"] is True + assert m._scheduler_health_v2(app, now=far, market_open=False)["swing"]["stale"] is False + + +def test_no_heartbeat_not_stale_but_dead_task_reported(): + app = _app() + + class D: + def done(self): + return True + app.state.day_trade_task = D() + m._scheduler_tasks["daytrading"] = "day_trade_task" + h = m._scheduler_health_v2(app, market_open=True)["daytrading"] + assert h["alive"] is False and h["last_cycle_age"] is None + + +@pytest.mark.asyncio +async def test_crash_triggers_alert_and_restart(_clean): + app = _app() + runs = [] + + async def boom(): + runs.append(1) + if len(runs) == 1: + raise RuntimeError("x") + await asyncio.sleep(60) + + m._spawn_scheduler_v2(app, "swing_trade_task", "swing", boom) + await asyncio.sleep(0.05) + assert len(runs) == 2 + assert any(c[0] == "SCHEDULER" for c in _clean) + app.state.swing_trade_task.cancel() + + +@pytest.mark.asyncio +async def test_restart_limit(_clean, monkeypatch): + app = _app() + monkeypatch.setattr(m, "_HB_SCHEDULER_MAX_RESTARTS_PER_HOUR", 2) + runs = [] + + async def boom(): + runs.append(1) + raise RuntimeError("x") + + m._spawn_scheduler_v2(app, "swing_trade_task", "swing", boom) + await asyncio.sleep(0.1) + monkeypatch.setattr(m, "_HB_SCHEDULER_LIMIT_COOLDOWN_SEC", 3600.0) + await asyncio.sleep(0.1) + assert len(runs) == 3 # 최초 + 재시작 2회 + assert any("한도" in c[1] for c in _clean) + for h in m._restart_handles.values(): + h.cancel() + + +@pytest.mark.asyncio +async def test_cancel_does_not_restart(_clean): + app = _app() + runs = [] + + async def ok(): + runs.append(1) + await asyncio.sleep(60) + + m._spawn_scheduler_v2(app, "swing_trade_task", "swing", ok) + await asyncio.sleep(0.01) + app.state.swing_trade_task.cancel() + await asyncio.sleep(0.02) + assert len(runs) == 1 and not _clean + + +# ── 하드닝 R2 ────────────────────────────────────────────────────────── +def test_reset_daily_loss_does_not_touch_mdd(monkeypatch): + """MDD reset 계열은 예외 발생형 목 — 호출되면 즉시 실패. DT daily_pnl 만 0 이 된다.""" + from app.services import mdd_manager as mm + from app.services.daytrading.position_manager import day_trading_state + + def boom(*a, **k): + raise AssertionError("MDD reset must not be called") + for n in ("reset", "reset_v2"): + monkeypatch.setattr(mm.mdd_manager, n, boom, raising=False) + day_trading_state.daily_pnl = -12345.0 + out = asyncio.run(m.reset_daily_loss(api_key="x")) + assert day_trading_state.daily_pnl == 0.0 + assert out["previous_daily_pnl"] == -12345.0 + + +def test_failed_trade_retention_is_long(): + assert m._FAILED_TRADE_RETENTION_DAYS >= 30 + + +def test_dt_scheduler_beat_makes_stale_detectable(): + from app.services.daytrading import scheduler as dts + app = _app() + + class T: + def done(self): + return False + app.state.day_trade_task = T() + m._scheduler_tasks["daytrading"] = "day_trade_task" + dts._beat_v2("loop") + far = hb._beats["daytrading"]["at"] + m._HB_SCHEDULER_STALE_SECONDS + 5 + assert m._scheduler_health_v2(app, now=far, market_open=True)["daytrading"]["stale"] is True + + +@pytest.mark.asyncio +async def test_dt_exit_consecutive_failures_alert(monkeypatch): + from app.services.daytrading import scheduler as dts + sent = [] + monkeypatch.setattr(dts, "_notify_dt_warning_bg_v2", lambda msg: sent.append(msg)) + dts._record_dt_exit_result_v2(True) + for _ in range(dts.DT_EXIT_FAILURE_ALERT_THRESHOLD - 1): + dts._record_dt_exit_result_v2(False, "timeout") + assert sent == [] + dts._record_dt_exit_result_v2(False, "timeout") + assert len(sent) == 1 + dts._record_dt_exit_result_v2(True) + assert dts._dt_exit_health["consecutive_failures"] == 0 + + +# ── 하드닝 라운드1: 백오프 / 한도 후 복구 / dead+age None / KIS 스트림 ────── +@pytest.mark.asyncio +async def test_restart_uses_backoff_delay(monkeypatch): + app = _app() + monkeypatch.setattr(m, "_HB_SCHEDULER_RESTART_BACKOFF_SEC", (0.2, 0.4)) + runs = [] + + async def boom(): + runs.append(1) + raise RuntimeError("x") + + m._spawn_scheduler_v2(app, "swing_trade_task", "swing", boom) + await asyncio.sleep(0.05) + assert len(runs) == 1 # 지연 중 즉시 재호출 없음 + await asyncio.sleep(0.25) + assert len(runs) == 2 + for h in m._restart_handles.values(): + h.cancel() + + +@pytest.mark.asyncio +async def test_restart_resumes_after_limit_cooldown(monkeypatch): + import time as _real_time + app = _app() + monkeypatch.setattr(m, "_HB_SCHEDULER_MAX_RESTARTS_PER_HOUR", 1) + monkeypatch.setattr(m, "_HB_SCHEDULER_LIMIT_COOLDOWN_SEC", 0.05) + offset = {"v": 0.0} + monkeypatch.setattr(m, "time", types.SimpleNamespace( + monotonic=lambda: _real_time.monotonic() + offset["v"])) + runs = [] + + async def boom(): + runs.append(1) + raise RuntimeError("x") + + # 한도(1회) 소진 상태에서 시작: 창이 지나기 전엔 재시도해도 재시작하지 않는다 + m._spawn_scheduler_v2(app, "swing_trade_task", "swing", boom) + await asyncio.sleep(0.03) + assert len(runs) == 2 + assert "swing" in m._restart_handles # 영구 포기가 아니라 재시도 예약됨 + n_before = len(runs) + offset["v"] = 4000.0 # 1시간 창 경과 가정 -> 다음 쿨다운 재시도가 통과 + # 이미 예약된 핸들은 ~3600s 뒤라 직접 재예약: 핸들 취소 후 새 크래시 유발 대신 상태 검증 + h = m._restart_handles["swing"] + assert h.when() - asyncio.get_running_loop().time() > 100 # 창이 풀릴 때까지 대기(백오프 아님) + h.cancel() + m._restart_handles.clear() + m._shutting_down = True + assert len(runs) == n_before + + +def test_dead_watched_scheduler_with_no_heartbeat_is_flagged_in_market(): + app = _app() + + class D: + def done(self): + return True + app.state.swing_trade_task = D() + m._scheduler_tasks["swing"] = "swing_trade_task" + h = m._scheduler_health_v2(app, market_open=True)["swing"] + assert h["alive"] is False and h["last_cycle_age"] is None and h["stale"] is True + assert m._scheduler_health_v2(app, market_open=False)["swing"]["stale"] is False + + +def test_kis_stream_silent_and_probe(monkeypatch): + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", None) + + class T: + def __init__(self, d): + self._d = d + + def done(self): + return self._d + app = _app() + app.state.kis_stream_task = None + assert m._kis_stream_health_v2(app, market_open=True) is None + app.state.kis_stream_task = T(False) + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) + limit = m._HB_KIS_STREAM_SILENT_SECONDS + assert m._kis_stream_health_v2(app, now=100.0 + limit + 1, market_open=True)["silent"] is True + assert m._kis_stream_health_v2(app, now=100.0 + limit + 1, market_open=False)["silent"] is False + assert m._kis_stream_health_v2(app, now=101.0, market_open=True)["silent"] is False + app.state.kis_stream_task = T(True) # 태스크 사망 + 장중 + assert m._kis_stream_health_v2(app, now=101.0, market_open=True)["silent"] is True + + seen = [] + + class S: + def _enqueue_realtime(self, t, c, p): + seen.append((t, c)) + s = S() + assert m._install_kis_stream_probe_v2(s) is True + assert m._install_kis_stream_probe_v2(s) is False # 멱등 + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", None) + s._enqueue_realtime("tick", "005930", {}) + assert seen == [("tick", "005930")] and m._kis_stream_last_msg_mono is not None + + +@pytest.mark.asyncio +async def test_kis_stream_done_callback_alerts(_clean): + async def boom(): + raise RuntimeError("ws") + t = asyncio.create_task(boom()) + t.add_done_callback(m._on_kis_stream_done_v2) + await asyncio.sleep(0.02) + assert any(c[0] == "KIS_STREAM" for c in _clean) + + +@pytest.mark.asyncio +async def test_limit_exhausted_scheduler_restarts_when_window_frees(monkeypatch): + import time as _t + app = _app() + monkeypatch.setattr(m, "_HB_SCHEDULER_MAX_RESTARTS_PER_HOUR", 1) + monkeypatch.setattr(m, "_HB_SCHEDULER_LIMIT_COOLDOWN_SEC", 0.05) + m._scheduler_restarts["swing"] = [_t.monotonic() - 3599.5] # 0.5초 뒤 창에서 빠짐 + runs = [] + + async def work(): + runs.append(1) + if len(runs) == 1: + raise RuntimeError("x") + await asyncio.sleep(60) + + m._spawn_scheduler_v2(app, "swing_trade_task", "swing", work) + await asyncio.sleep(0.1) + assert len(runs) == 1 # 한도 소진 — 아직 대기 + await asyncio.sleep(1.8) + assert len(runs) == 2 # 창이 풀리자 자동 복구 + app.state.swing_trade_task.cancel() diff --git a/backend/tests/test_main_migration_head_r6.py b/backend/tests/test_main_migration_head_r6.py new file mode 100644 index 0000000..e5dd502 --- /dev/null +++ b/backend/tests/test_main_migration_head_r6.py @@ -0,0 +1,146 @@ +"""하드닝 R6: DB alembic_version ↔ 코드 head 대조가 /health 에 노출된다.""" +import pytest + +from app import main as m + + +class _Res: + def __init__(self, rows): + self._rows = rows + + def fetchall(self): + return self._rows + + +class _Conn: + def __init__(self, rows=None, exc=None): + self.rows, self.exc = rows, exc + + async def execute(self, *_a, **_k): + if self.exc: + raise self.exc + return _Res(self.rows) + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch): + monkeypatch.setattr(m, "_migration_state", {"checked": False, "db": None, "head": None, "mismatch": False}) + + +def test_code_head_readable(): + assert len(m._code_alembic_heads_v2()) >= 1 + + +@pytest.mark.asyncio +async def test_match_is_not_mismatch(): + heads = m._code_alembic_heads_v2() + st = await m._check_migration_head_v2(_Conn([(h,) for h in heads])) + assert st["mismatch"] is False and st["checked"] is True + + +@pytest.mark.asyncio +async def test_old_db_version_is_mismatch(): + st = await m._check_migration_head_v2(_Conn([("0011",)])) + assert st["mismatch"] is True and st["db"] == "0011" + + +@pytest.mark.asyncio +async def test_missing_table_raises_not_silent(): + with pytest.raises(RuntimeError): + await m._check_migration_head_v2(_Conn(exc=RuntimeError("no alembic_version"))) + assert m._migration_state["mismatch"] is False # 불일치로 단정하지 않는다 + + +@pytest.mark.asyncio +async def test_health_degraded_reason_on_mismatch(monkeypatch): + from app.services import db_worker as w + from app.core import redis as _redis + + class _NoDb: + def connect(self): + raise RuntimeError("test: real DB access blocked") + monkeypatch.setattr(m, "engine", _NoDb()) + monkeypatch.setattr(_redis, "get_redis", lambda: None) + + async def dlq(): + return {"pending": 0, "manual": 0, "expired": 0} + monkeypatch.setattr(w, "dlq_status_counts", dlq) + monkeypatch.setattr(m, "_runtime_health_v2", lambda market_open=None: ({}, [])) + m._migration_state["mismatch"] = True + out = await m._health_check_impl() + assert "migration_mismatch" in out["degraded_reasons"] and out["status"] == "degraded" + + +@pytest.mark.asyncio +async def test_mismatch_blocks_entry_independent_of_fail_closed(monkeypatch): + """R8: SECURITY_FAIL_CLOSED=False 여도 불일치면 can_open_new_position 이 False.""" + from app.core.config import settings + + monkeypatch.setattr(settings, "SECURITY_FAIL_CLOSED", False) + + class _Mgr: + async def can_open_new_position(self): + return True + + mgr = _Mgr() + m._install_migration_entry_guard_v2(mgr) + m._migration_state["mismatch"] = False + assert await mgr.can_open_new_position() is True + m._migration_state["mismatch"] = True + assert await mgr.can_open_new_position() is False + m._migration_state["mismatch"] = False # 복구 시 자동 해제 + assert await mgr.can_open_new_position() is True + + +@pytest.mark.asyncio +async def test_guard_install_is_idempotent(): + class _Mgr: + async def can_open_new_position(self): + return True + + mgr = _Mgr() + m._install_migration_entry_guard_v2(mgr) + first = mgr.can_open_new_position + m._install_migration_entry_guard_v2(mgr) + assert mgr.can_open_new_position is first + + +@pytest.mark.asyncio +async def test_recheck_loop_repeats_warning_and_recovers(monkeypatch): + sent = [] + monkeypatch.setattr(m, "_notify_system_warning_bg", lambda c, msg: sent.append(c)) + states = iter([True, True, False]) + + async def _fake_check(_conn): + m._migration_state.update(checked=True, db="0011", head="0012", mismatch=next(states)) + return dict(m._migration_state) + + class _Ctx: + async def __aenter__(self): + return object() + + async def __aexit__(self, *a): + return False + + class _Eng: + def connect(self): + return _Ctx() + + monkeypatch.setattr(m, "engine", _Eng()) + monkeypatch.setattr(m, "_check_migration_head_v2", _fake_check) + m._migration_state["mismatch"] = True + await m._migration_recheck_loop_v2(interval_sec=0, iterations=3) + assert sent == ["migration_mismatch", "migration_mismatch"] + assert m._migration_state["mismatch"] is False + + +@pytest.mark.asyncio +async def test_recheck_failure_keeps_mismatch(monkeypatch): + class _Eng: + def connect(self): + raise RuntimeError("db down") + + monkeypatch.setattr(m, "engine", _Eng()) + m._migration_state["mismatch"] = True + await m._migration_recheck_loop_v2(interval_sec=0, iterations=1) + assert m._migration_state["mismatch"] is True diff --git a/backend/tests/test_main_shutdown_ws_r3_20261009.py b/backend/tests/test_main_shutdown_ws_r3_20261009.py new file mode 100644 index 0000000..1b45133 --- /dev/null +++ b/backend/tests/test_main_shutdown_ws_r3_20261009.py @@ -0,0 +1,170 @@ +"""하드닝 R3: 종료 drain 의 주문 경로 대기 + WebSocket 세션 재검증/소유권.""" +import asyncio +import time + +import pytest + +from app import main as m +from app.core.config import settings + + +@pytest.fixture(autouse=True) +def _iso(monkeypatch): + calls = [] + monkeypatch.setattr(m, "_notify_system_warning_bg", lambda c, msg: calls.append((c, msg))) + monkeypatch.setattr(m, "_ws_owners", {}) + return calls + + +@pytest.mark.asyncio +async def test_drain_waits_for_swing_inflight_and_fill_reconciler(monkeypatch): + from app.services.swing import scheduler as sw + from app.services.daytrading import fill_reconciler as fr + + done = [] + + async def _work(tag): + await asyncio.sleep(0.05) + done.append(tag) + + t1 = asyncio.ensure_future(_work("swing")) + t2 = asyncio.ensure_future(_work("fill")) + monkeypatch.setattr(sw, "_inflight_steps", {"entry": t1}) + monkeypatch.setattr(fr, "_pending_tasks", {t2}) + n = await m._drain_order_path_tasks_v2(float(settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS)) + assert n == 0 and sorted(done) == ["fill", "swing"] + + +@pytest.mark.asyncio +async def test_drain_waits_for_shielded_inner_coroutine(monkeypatch): + from app.services.swing import scheduler as sw + from app.services.daytrading import fill_reconciler as fr + monkeypatch.setattr(sw, "_inflight_steps", {}) + monkeypatch.setattr(fr, "_pending_tasks", set()) + finished = [] + + async def _place_and_finalize_exit_guarded_impl(): + await asyncio.sleep(0.05) + finished.append(1) + + outer = asyncio.ensure_future(asyncio.shield(_place_and_finalize_exit_guarded_impl())) + await asyncio.sleep(0) + outer.cancel() # 판정 코루틴 취소 — 내부 shield 작업은 계속 돈다 + await m._drain_order_path_tasks_v2(5.0) + assert finished == [1] + + +@pytest.mark.asyncio +async def test_drain_timeout_cancels_and_alerts(monkeypatch, _iso): + from app.services.swing import scheduler as sw + from app.services.daytrading import fill_reconciler as fr + monkeypatch.setattr(fr, "_pending_tasks", set()) + + async def _stuck(): + await asyncio.sleep(60) + + t = asyncio.ensure_future(_stuck()) + monkeypatch.setattr(sw, "_inflight_steps", {"exit": t}) + n = await m._drain_order_path_tasks_v2(0.05) + assert n == 1 and t.cancelled() + assert _iso and _iso[0][0] == "SHUTDOWN_ORDER_TASKS" and "_stuck" in _iso[0][1] + + +@pytest.mark.asyncio +async def test_all_drain_includes_order_path(monkeypatch): + from app.services import trade_executor as te + called = [] + + async def _od(timeout): + called.append(timeout) + return 0 + + async def _te(timeout): + return 0 + monkeypatch.setattr(m, "_drain_order_path_tasks_v2", _od) + monkeypatch.setattr(te, "drain_tracked_tasks_v2", _te) + await m._drain_all_background_tasks_v2(1.0) + assert called == [1.0] + + +def test_ws_exp_and_owner(): + from app.core import security as sec + tok, _ = sec.create_session_token("bob") + exp = m._ws_token_exp_v2(tok) + assert exp is not None and exp > time.time() + assert m._ws_token_exp_v2("api-key-plain") is None + m._ws_owners["c1"] = "bob" + assert m._ws_owner_may_connect_v2("c1", "bob", {"c1": 1}) is True + assert m._ws_owner_may_connect_v2("c1", "eve", {"c1": 1}) is False + assert m._ws_owner_may_connect_v2("c2", "eve", {"c1": 1}) is True + + +@pytest.mark.asyncio +async def test_ws_session_revalidation(monkeypatch): + from app.core import security as sec + # exp 초과 + assert await m._ws_session_still_valid_v2("t", time.time() - 1, recheck=False) is False + assert await m._ws_session_still_valid_v2("t", time.time() + 100, recheck=False) is True + + async def _revoked(tok): + return None + monkeypatch.setattr(sec, "resolve_credential_v2", _revoked) + assert await m._ws_session_still_valid_v2("t", None, recheck=True) is False + + async def _boom(tok): + raise RuntimeError("db down") + monkeypatch.setattr(sec, "resolve_credential_v2", _boom) + assert await m._ws_session_still_valid_v2("t", None, recheck=True) is True + + +# ── 하드닝 R5: WS 재확인 fail-closed / 요청 제한 ───────────────────────────── +@pytest.mark.asyncio +async def test_ws_recheck_closes_after_consecutive_failures(monkeypatch): + from app.core import security + + async def _boom(token): + raise RuntimeError("db down") + + monkeypatch.setattr(security, "resolve_credential_v2", _boom) + st = {"fails": 0} + limit = int(m._WS_RECHECK_MAX_FAILURES) + for _ in range(limit - 1): + assert await m._ws_session_still_valid_v3("t", None, True, st) is True + assert await m._ws_session_still_valid_v3("t", None, True, st) is False + + +@pytest.mark.asyncio +async def test_ws_recheck_success_resets_counter(monkeypatch): + from app.core import security + mode = {"fail": True} + + async def _res(token): + if mode["fail"]: + raise RuntimeError("x") + return object() + + monkeypatch.setattr(security, "resolve_credential_v2", _res) + st = {"fails": 0} + await m._ws_session_still_valid_v3("t", None, True, st) + mode["fail"] = False + assert await m._ws_session_still_valid_v3("t", None, True, st) is True + assert st["fails"] == 0 + + +def test_ws_cap_codes_limits_and_dedupes(): + codes = [f"C{i}" for i in range(m._WS_MAX_SUBSCRIBE_CODES + 50)] + ["C0", " "] + out = m._ws_cap_codes_v2(codes) + assert len(out) == m._WS_MAX_SUBSCRIBE_CODES and len(set(out)) == len(out) + + +def test_ws_chart_limiter_per_second_and_minute(): + t = {"now": 0.0} + lim = m._WsChartLimiterV2(2, 3, clock=lambda: t["now"]) + assert lim.allow() and lim.allow() + assert not lim.allow() # 초당 초과 + t["now"] = 1.5 + assert lim.allow() # 분당 3번째 + t["now"] = 3.0 + assert not lim.allow() # 분당 초과 + t["now"] = 61.0 + assert lim.allow() diff --git a/backend/tests/test_market_analyzer_krx_merge_r2.py b/backend/tests/test_market_analyzer_krx_merge_r2.py new file mode 100644 index 0000000..1843289 --- /dev/null +++ b/backend/tests/test_market_analyzer_krx_merge_r2.py @@ -0,0 +1,76 @@ +"""배치 분석 네이버 일봉을 KRX 가격열로 정합 (하드닝 R2).""" +import pytest +from app.services import market_analyzer as ma + + +def _n(d, c, v=100): + return {"stck_bsop_date": d, "stck_clpr": str(c), "stck_oprc": str(c), "stck_hgpr": str(c), + "stck_lwpr": str(c), "acml_vol": str(v)} + + +def test_merge_overrides_same_date_keeps_others(): + nv = [_n("20261001", 274750), _n("20261002", 100), _n("20261007", 50)] + krx = [_n("20261001", 274500, 999), _n("20261002", 0)] # 0 은 무시 + out = ma._merge_krx_over_naver_v2(nv, krx) + assert out[0]["stck_clpr"] == "274500" and out[0]["acml_vol"] == "999" and out[0]["krx_overridden"] + assert out[1]["stck_clpr"] == "100" and "krx_overridden" not in out[1] + assert out[2]["stck_clpr"] == "50" # KRX 결측일(당일 장중 봉)은 네이버 유지 + assert nv[0]["stck_clpr"] == "274750" # 입력 불변 + assert len(out) == len(nv) + + +def test_merge_empty_inputs(): + assert ma._merge_krx_over_naver_v2(None, [_n("20261001", 1)]) is None + nv = [_n("20261001", 5)] + assert ma._merge_krx_over_naver_v2(nv, []) == nv + + +@pytest.mark.asyncio +async def test_analyze_stock_uses_krx_close_and_survives_db_failure(monkeypatch): + seen = {} + from app.services import naver_finance as nf + + async def fake_naver(code, days=80, client=None): + return [_n("20261001", 274750), _n("20261002", 10)] + + monkeypatch.setattr(nf, "get_naver_daily_chart", fake_naver) + + def fake_items(chart, code, name, crit, ml_chart_data=None): + seen["chart"] = chart + return "ok" + + monkeypatch.setattr(ma, "_analyze_chart_items_v2", fake_items) + crit = ma.RecommendationCriteria(for_batch=True) + + async def krx_ok(code, limit=240): + return [_n("20261001", 274500)] + monkeypatch.setattr(ma, "_fetch_krx_rows_v2", krx_ok) + assert await ma.analyze_stock("005930", "x", crit) == "ok" + assert seen["chart"][0]["stck_clpr"] == "274500" + + async def krx_fail(code, limit=240): + return [] + monkeypatch.setattr(ma, "_fetch_krx_rows_v2", krx_fail) + assert await ma.analyze_stock("005930", "x", crit) == "ok" + assert seen["chart"][0]["stck_clpr"] == "274750" + + +def test_merge_skipped_on_split_discontinuity_ls_electric_shape(): + """1:5 분할(원주가 788,000 → 179,200) 형태: 네이버는 수정주가, KRX 는 원주가.""" + from app.api.endpoints import market_data as md + assert ma._KRX_SPLIT_MISMATCH_RATIO == md._CHART_SPLIT_MISMATCH_RATIO + nv = [_n("20260410", 157600), _n("20260413", 179200), _n("20260414", 180000)] + krx = [_n("20260410", 788000), _n("20260413", 179200), _n("20260414", 180000)] + out = ma._merge_krx_over_naver_v2(nv, krx) + assert [r["stck_clpr"] for r in out] == ["157600", "179200", "180000"] + assert not any(r.get("krx_overridden") for r in out) + + +def test_stale_intraday_bar_flag(monkeypatch): + from datetime import datetime + now = datetime(2026, 10, 8, 11, 0, tzinfo=ma.KST) + monkeypatch.setattr(ma, "is_market_open", lambda: True) + assert ma._is_stale_intraday_bar_v2("20261007", now) is True + assert ma._is_stale_intraday_bar_v2("20261008", now) is False + monkeypatch.setattr(ma, "is_market_open", lambda: False) # 장전은 전일 봉 정상 + assert ma._is_stale_intraday_bar_v2("20261007", now) is False diff --git a/backend/tests/test_market_analyzer_offload_r4.py b/backend/tests/test_market_analyzer_offload_r4.py new file mode 100644 index 0000000..f25c55f --- /dev/null +++ b/backend/tests/test_market_analyzer_offload_r4.py @@ -0,0 +1,43 @@ +"""R4: 분석(ML 추론 포함)이 이벤트 루프 스레드에서 실행되지 않아야 한다.""" +import asyncio +import threading +import time +import pytest + +from app.services import market_analyzer as ma + + +@pytest.mark.asyncio +async def test_analyze_runs_off_loop_thread(monkeypatch): + main_tid = threading.get_ident() + seen = {} + + def fake(chart_data, code, name, criteria, ml_chart_data=None): + seen["tid"] = threading.get_ident() + return "OK" + + monkeypatch.setattr(ma, "_analyze_chart_items_v2", fake) + res = await ma.analyze_stock_for_batch_db([{"a": 1}], "005930", "삼성") + assert res == "OK" + assert seen["tid"] != main_tid + + +@pytest.mark.asyncio +async def test_loop_not_blocked_during_analysis(monkeypatch): + def slow(*a, **k): + time.sleep(0.3) + return None + + monkeypatch.setattr(ma, "_analyze_chart_items_v2", slow) + ticks = 0 + + async def ticker(): + nonlocal ticks + while True: + await asyncio.sleep(0.02) + ticks += 1 + + t = asyncio.create_task(ticker()) + await ma.analyze_stock_for_batch_db([{"a": 1}], "005930", "삼성") + t.cancel() + assert ticks >= 5 diff --git a/backend/tests/test_market_analyzer_volratio_r3.py b/backend/tests/test_market_analyzer_volratio_r3.py new file mode 100644 index 0000000..a4d1380 --- /dev/null +++ b/backend/tests/test_market_analyzer_volratio_r3.py @@ -0,0 +1,40 @@ +"""거래량비 장중 시간 정규화 (하드닝 R3).""" +from datetime import datetime +from app.services import market_analyzer as ma + +KST = ma.KST + + +def _now(h, m, d=(2026, 10, 8)): # 목요일 + return KST.localize(datetime(d[0], d[1], d[2], h, m)) + + +def test_intraday_partial_bar_is_normalized(): + vols = [1000] * 20 + [250] + r = ma._volume_ratio_v2(vols, 250, "20261008", _now(10, 37)) + assert 0.95 < r < 1.05 + assert 250 / (sum(vols[-20:]) / 20) < 0.3 # 기존 방식은 구조적으로 낮았다 + + +def test_today_bar_excluded_from_denominator(): + vols = [1000] * 20 + [10_000_000] + r = ma._volume_ratio_v2(vols, 10_000_000, "20261008", _now(15, 29)) + assert r > 9000 + + +def test_early_open_fraction_floor(): + vols = [1000] * 21 + r = ma._volume_ratio_v2(vols, 10, "20261008", _now(9, 1)) + assert r == 10 / (1000 * ma._VOL_MIN_ELAPSED_FRACTION) + + +def test_completed_or_past_bar_unchanged(): + vols = [1000] * 19 + [2000] + legacy = 2000 / (sum(vols[-20:]) / 20) + assert ma._volume_ratio_v2(vols, 2000, "20261008", _now(16, 0)) == legacy + assert ma._volume_ratio_v2(vols, 2000, "20261007", _now(10, 0)) == legacy + assert ma._volume_ratio_v2(vols, 2000, "20261010", _now(10, 0, (2026, 10, 10))) == legacy + + +def test_insufficient_history(): + assert ma._volume_ratio_v2([1000] * 10, 5, "20261008", _now(10, 0)) == 0 diff --git a/backend/tests/test_market_intelligence_news.py b/backend/tests/test_market_intelligence_news.py new file mode 100644 index 0000000..53dee9e --- /dev/null +++ b/backend/tests/test_market_intelligence_news.py @@ -0,0 +1,49 @@ +"""market_intelligence 뉴스 감성 섹션 계약 테스트 (인자 불일치 TypeError 회귀).""" +import asyncio + +import pytest + +from app.api.endpoints import market_intelligence as mi +from app.services import news_sentiment as ns + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _boom(*a, **k): + raise AssertionError("잘못된 함수 호출") + + +def test_market_score_wrapped_in_dict(monkeypatch): + async def market(): + return 0.25 + monkeypatch.setattr(ns, "get_market_news_score", market) + monkeypatch.setattr(ns, "get_stock_news_score", _boom) + res = _run(mi._get_news_sentiment()) + assert res["market_score"] == 0.25 + assert res["reason"] is None + assert {"top_positive", "top_negative"} <= set(res) + + +def test_stock_code_uses_stock_score(monkeypatch): + seen = {} + + async def stock(code): + seen["code"] = code + return -0.4 + monkeypatch.setattr(ns, "get_stock_news_score", stock) + monkeypatch.setattr(ns, "get_market_news_score", _boom) + res = _run(mi._get_news_sentiment(stock_code="005930")) + assert seen["code"] == "005930" + assert res["market_score"] == -0.4 + + +def test_endpoint_contract(monkeypatch): + async def market(): + return 0.1 + monkeypatch.setattr(ns, "get_market_news_score", market) + res = _run(mi.get_market_news(stock_code=None)) + assert res["market_score"] == 0.1 + assert "timestamp" in res and res["stock_code"] is None + assert not str(res.get("reason") or "").startswith("조회 실패") diff --git a/backend/tests/test_market_regime_offhours_r2.py b/backend/tests/test_market_regime_offhours_r2.py new file mode 100644 index 0000000..07bcd12 --- /dev/null +++ b/backend/tests/test_market_regime_offhours_r2.py @@ -0,0 +1,30 @@ +import time +import pytest +from app.services import market_analyzer as ma + + +@pytest.fixture(autouse=True) +def _restore(): + saved = dict(ma._market_regime_cache) + yield + ma._market_regime_cache.update(saved) + + +@pytest.mark.asyncio +async def test_offhours_uses_last_intraday_regime(monkeypatch): + monkeypatch.setattr(ma, "is_market_open", lambda: False) + ma._market_regime_cache.update(result={"regime": "bullish", "regime_200d": "bull"}, ts=time.monotonic()) + r = await ma.check_market_regime() + assert r["regime_200d"] == "bull" + assert r["position_multiplier"] == 0.5 + + +@pytest.mark.asyncio +async def test_offhours_stale_or_missing_falls_back_bear(monkeypatch): + monkeypatch.setattr(ma, "is_market_open", lambda: False) + ma._market_regime_cache.update(result=None, ts=0.0) + assert (await ma.check_market_regime())["regime_200d"] == "bear" + ma._market_regime_cache.update( + result={"regime_200d": "bull"}, + ts=time.monotonic() - ma._OFFHOURS_REGIME_MAX_AGE_SEC - 10) + assert (await ma.check_market_regime())["regime_200d"] == "bear" diff --git a/backend/tests/test_mdd_active_trade_scope_r5.py b/backend/tests/test_mdd_active_trade_scope_r5.py new file mode 100644 index 0000000..67e2a76 --- /dev/null +++ b/backend/tests/test_mdd_active_trade_scope_r5.py @@ -0,0 +1,71 @@ +"""R5: MDD 축소는 DT 인메모리가 0 이어도 전략명 없는 close/upsert 를 내지 않는다.""" +import pytest + +from app.services import mdd_manager as m +from app.services.db_worker import DT_STRATEGY_NAMES + + +@pytest.fixture +def cap(monkeypatch): + out = {"up": [], "wr": []} + + async def _up(p): + out["up"].append(p) + + async def _wr(p): + out["wr"].append(p) + + async def _boom(*a, **k): + raise AssertionError("전략명 없는 close 금지") + + monkeypatch.setattr("app.services.db_worker.enqueue_active_trade_upsert", _up) + monkeypatch.setattr("app.services.db_worker.enqueue_db_write", _wr) + monkeypatch.setattr("app.services.db_worker.enqueue_close_active_trade", _boom) + monkeypatch.setattr("app.services.db_worker.enqueue_close_active_trade_v2", _boom) + monkeypatch.setattr(m, "_dt_position_qty", lambda _c: 0) + return out + + +@pytest.mark.asyncio +async def test_full_close_without_dt_uses_non_dt_filter(cap): + await m._sync_active_trade_after_reduction("005930", 10, 0) + assert len(cap["wr"]) == 1 + d = cap["wr"][0]["data"] + assert cap["wr"][0]["type"] == "close_active_trade" + assert tuple(d["strategy_name_not"]) == tuple(DT_STRATEGY_NAMES) + + +@pytest.mark.asyncio +async def test_partial_without_dt_uses_non_dt_filter(cap): + await m._sync_active_trade_after_reduction("005930", 10, 7) + assert len(cap["up"]) == 1 + p = cap["up"][0] + assert p["quantity"] == 7 + assert tuple(p["strategy_name_not"]) == tuple(DT_STRATEGY_NAMES) + + +def test_dt_qty_failure_is_logged_not_silent(monkeypatch, caplog): + import app.services.daytrading.position_manager as pm + + class _Boom: + @property + def positions(self): + raise RuntimeError("boom") + + monkeypatch.setattr(pm, "day_trading_state", _Boom()) + with caplog.at_level("WARNING"): + assert m._dt_position_qty("005930") == 0 + assert any("DT 인메모리 수량 조회 실패" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("remaining", [0, 3, 7]) +async def test_dt_present_never_emits_strategyless_payload(cap, monkeypatch, remaining): + """DT 병존 시에도 모든 close/upsert payload 는 전략 필터를 가진다.""" + monkeypatch.setattr(m, "_dt_position_qty", lambda _c: 4) + await m._sync_active_trade_after_reduction("005930", 10, remaining) + for p in cap["up"]: + assert p.get("strategy_name") or p.get("strategy_name_not") + for w in cap["wr"]: + d = w["data"] + assert d.get("strategy_name") or d.get("strategy_names") or d.get("strategy_name_not") diff --git a/backend/tests/test_mdd_alert_retry_r4_20261009.py b/backend/tests/test_mdd_alert_retry_r4_20261009.py new file mode 100644 index 0000000..be1d348 --- /dev/null +++ b/backend/tests/test_mdd_alert_retry_r4_20261009.py @@ -0,0 +1,59 @@ +"""하드닝 R4 — MDD 단계 전이 텔레그램 재시도/참조 보관 회귀 테스트.""" +import asyncio +from unittest.mock import AsyncMock + +import pytest + + +async def _drain(): + import app.services.mdd_manager as m + while m._alert_tasks: + await asyncio.gather(*list(m._alert_tasks), return_exceptions=True) + + +@pytest.fixture +def env(monkeypatch): + import app.services.mdd_manager as m + import app.services.telegram_notifier as tn + monkeypatch.setattr(m, "_alert_backoff_v2", lambda: 0.0) + monkeypatch.setattr(m, "_alert_retries_v2", lambda: 2) + mgr = m.MDDManager() + monkeypatch.setattr(mgr, "_write_state_v2", AsyncMock()) + return m, tn, mgr + + +@pytest.mark.asyncio +async def test_retries_on_exception_then_succeeds(env, monkeypatch): + m, tn, mgr = env + mock = AsyncMock(side_effect=[RuntimeError("boom"), False, None]) + monkeypatch.setattr(tn.telegram_notifier, "notify_mdd_warning", mock) + await mgr.update(100.0) + r = await mgr.update(100.0 * (1 + (-10.5) / 100)) + assert r["action"] == "REDUCE_AND_PAUSE" + assert m._alert_tasks # 참조 보관 + await _drain() + assert mock.await_count == 3 + + +@pytest.mark.asyncio +async def test_final_failure_logged_error(env, monkeypatch, caplog): + m, tn, mgr = env + mock = AsyncMock(return_value=False) + monkeypatch.setattr(tn.telegram_notifier, "notify_mdd_warning", mock) + await mgr.update(100.0) + r = await mgr.update(79.0) + assert r["action"] == "FORCE_REDUCE" + with caplog.at_level("ERROR"): + await _drain() + assert mock.await_count == m._alert_retries_v2() + 1 + assert any("최종 실패" in rec.message for rec in caplog.records) + assert not m._alert_tasks + + +def test_no_running_loop_logs_error_not_raise(env, caplog): + m, tn, mgr = env + mgr.peak_balance = 100.0 + with caplog.at_level("ERROR"): + r = mgr._update_locked_v2(79.0) + assert r["action"] == "FORCE_REDUCE" + assert any("예약 실패" in rec.message for rec in caplog.records) diff --git a/backend/tests/test_mdd_empty_account_stale_r8.py b/backend/tests/test_mdd_empty_account_stale_r8.py new file mode 100644 index 0000000..8a8362d --- /dev/null +++ b/backend/tests/test_mdd_empty_account_stale_r8.py @@ -0,0 +1,38 @@ +"""[R8] 빈 계좌 응답 후 swing 사이클 정지 시에도 stale fail-safe 가 작동해야 한다.""" +from datetime import datetime, timedelta + +import pytest + +from app.core.config import settings +from app.core import market_schedule as ms +from app.services import mdd_manager as mm + + +def _make(monkeypatch, now): + monkeypatch.setattr(settings, "MDD_BLIND_BLOCK_ENTRY", True) + monkeypatch.setattr(ms, "is_market_open", lambda *a, **k: True) + monkeypatch.setattr(mm.MDDManager, "_now_kst_v2", staticmethod(lambda: now[0])) + return mm.MDDManager() + + +@pytest.mark.asyncio +async def test_empty_account_then_stalled_blocks_entry(monkeypatch): + t0 = datetime(2026, 10, 12, 10, 0, tzinfo=ms.KST) + now = [t0] + m = _make(monkeypatch, now) + stale = mm._stale_sec_v2() + assert stale > 0 + await m.record_update_success(empty_account=True) + now[0] = t0 + timedelta(seconds=stale - 1) + assert await m.can_open_new_position() is True + now[0] = t0 + timedelta(seconds=stale + 1) + assert await m.can_open_new_position() is False + + +@pytest.mark.asyncio +async def test_empty_account_exempt_from_failure_counter(monkeypatch): + t0 = datetime(2026, 10, 12, 10, 0, tzinfo=ms.KST) + m = _make(monkeypatch, [t0]) + await m.record_update_success(empty_account=True) + m._update_failures = int(settings.MDD_BLIND_FAILURE_THRESHOLD) + 3 + assert await m.can_open_new_position() is True diff --git a/backend/tests/test_mdd_reduction_net_pnl_r4_20261009.py b/backend/tests/test_mdd_reduction_net_pnl_r4_20261009.py new file mode 100644 index 0000000..9849ceb --- /dev/null +++ b/backend/tests/test_mdd_reduction_net_pnl_r4_20261009.py @@ -0,0 +1,88 @@ +"""[R4 2026-10-09] MDD 축소 매도 TradeHistory 가 비용 차감 순손익 + commission + 수익률을 기록한다. + +목킹 지점: 주문 종단 safe_place_order / 잔고 get_balance / 기록 종단 enqueue_trade_history(캡처). +""" +import pytest + +from app.core.config import settings +from app.services import mdd_manager as mm +from app.services import trade_executor as te +from app.services.korea_investment_api import korea_investment_api + + +class _FakeRedis: + def __init__(self): + self.store = {} + + async def set(self, k, v, **kw): + self.store[k] = v + return True + + async def get(self, k): + return self.store.get(k) + + +def _expected(avg, cur, qty): + comm = settings.KR_COMMISSION_RATE + tax = settings.KR_SELL_TAX_RATE_KOSPI + cost = round(avg * qty * comm + cur * qty * comm + cur * qty * tax, 0) + gross = (cur - avg) * qty + return gross - cost, cost, (gross - cost) / (avg * qty) * 100 + + +def test_helper_deducts_costs_settings_based(): + r = mm._reduction_net_pnl_v2(10000.0, 10100.0, 30) + net, cost, rate = _expected(10000.0, 10100.0, 30) + assert r["net_profit_loss"] == pytest.approx(net) + assert r["commission"] == pytest.approx(cost) + assert r["profit_loss_rate"] == pytest.approx(rate) + assert r["net_profit_loss"] < (10100.0 - 10000.0) * 30 # gross 보다 작다 + + +@pytest.mark.asyncio +async def test_reduction_payload_has_net_pnl(monkeypatch): + import app.core.redis as redis_mod + monkeypatch.setattr(redis_mod, "get_redis", lambda: _FakeRedis()) + + async def _nosleep(*a, **k): + return None + monkeypatch.setattr(mm.asyncio, "sleep", _nosleep) + import app.core.market_schedule as _ms + monkeypatch.setattr(_ms, "is_market_open", lambda: True) + + async def _noop(*a, **k): + return None + monkeypatch.setattr(mm, "_sync_active_trade_after_reduction", _noop) + monkeypatch.setattr(mm, "_reflect_reduction", _noop) + captured = [] + + async def _cap(payload): + captured.append(payload) + import app.services.db_worker as dbw + monkeypatch.setattr(dbw, "enqueue_trade_history", _cap) + + async def _bal(*a, **k): + return {"rt_cd": "0", "output1": [ + {"pdno": "005930", "prdt_name": "X", "hldg_qty": "100", "prpr": "10100", + "pchs_avg_pric": "10000"}]} + + async def _order(stock_code, order_type, quantity, price=None, **kw): + return {"rt_cd": "0", "output": {"ODNO": "O1"}} + monkeypatch.setattr(korea_investment_api, "get_balance", _bal) + monkeypatch.setattr(te, "safe_place_order", _order) + + # 알림 fire-and-forget 태스크가 다른 테스트 루프로 새지 않게 차단 + monkeypatch.setattr(mm.MDDManager, "_spawn_mdd_alert_v2", staticmethod(lambda *a, **k: None)) + m = mm.MDDManager() + await m.update(100.0) + await m.update(89.0) + await m.execute_reduction( + {"action": "REDUCE_AND_PAUSE", "reduce_pct": 0.30, "prioritize_leverage": False} + ) + assert len(captured) == 1 + p = captured[0] + net, cost, rate = _expected(10000.0, 10100.0, 30) + assert p["net_profit_loss"] == pytest.approx(net) + assert p["commission"] == pytest.approx(cost) + assert p["profit_loss_rate"] == pytest.approx(rate) + assert p["strategy_type"] == "mdd_reduction" diff --git a/backend/tests/test_mdd_reduction_retry_20261002.py b/backend/tests/test_mdd_reduction_retry_20261002.py index c6cfe43..5fd2c1e 100644 --- a/backend/tests/test_mdd_reduction_retry_20261002.py +++ b/backend/tests/test_mdd_reduction_retry_20261002.py @@ -408,7 +408,9 @@ async def test_m1r4_reset_mid_run_does_not_leave_stale_executed(env): await m.execute_reduction(_action(m)) assert m._reduction_executed_10pct is False, "reset 이전 세대 결과로 executed 가 섰다" assert acct.attempts == {"005930": 1}, "reset 이후 남은 종목 매도를 중단해야 한다" - r = await m.update(89.0) + # [R1] reset 은 peak 를 현재 잔고(89)로 재설정 — 새 -10% 이벤트는 추가 하락 시 발생 + assert (await m.update(89.0))["action"] == "NONE" + r = await m.update(79.0) assert r["action"] == "REDUCE_AND_PAUSE" await m.execute_reduction(_action(m)) finally: @@ -430,3 +432,99 @@ async def test_l1r4_no_reduction_orders_when_market_closed(env, monkeypatch): monkeypatch.setattr(_ms, "is_market_open", lambda: True) await m.execute_reduction(_action(m)) assert acct.sells["005930"] == [30] + + +# ── [R1 2026-10-09] reset peak 재설정 / 갱신 신선도 ───────────────────────────── +async def _noop_async(*a, **k): + return None + + +@pytest.mark.asyncio +async def test_reset_rebases_peak_so_reduction_not_retriggered(monkeypatch): + m = mm.MDDManager() + monkeypatch.setattr(m, "_write_state_v2", _noop_async) + assert (await m.update(1_000_000))["action"] == "NONE" + assert (await m.update(880_000))["action"] == "REDUCE_AND_PAUSE" + await m.reset() + assert m.peak_balance == 880_000 + assert (await m.update(880_000))["action"] == "NONE" + assert await m.can_open_new_position() is True + assert (await m.update(790_000))["action"] == "REDUCE_AND_PAUSE" + + +@pytest.mark.asyncio +async def test_reset_without_known_balance_rebases_on_next_update(monkeypatch): + m = mm.MDDManager() + monkeypatch.setattr(m, "_write_state_v2", _noop_async) + m.peak_balance = 1_000_000.0 + await m.reset() + assert (await m.update(850_000))["action"] == "NONE" + assert m.peak_balance == 850_000 + + +@pytest.mark.asyncio +async def test_reset_fail_safe_keeps_pause_and_peak(monkeypatch): + m = mm.MDDManager() + monkeypatch.setattr(m, "_write_state_v2", _noop_async) + await m.update(1_000_000) + await m.update(880_000) + m._update_failures = 99 + await m.reset_fail_safe_v2() + assert m.peak_balance == 1_000_000 and m.is_paused is True + assert m._update_failures == 0 + + +@pytest.mark.asyncio +async def test_stale_update_blocks_entry_during_market(monkeypatch): + from datetime import datetime, timedelta + from app.core import market_schedule as ms + m = mm.MDDManager() + base = datetime(2026, 10, 7, 10, 0, tzinfo=ms.KST) + clock = {"t": base} + monkeypatch.setattr(mm.MDDManager, "_now_kst_v2", staticmethod(lambda: clock["t"])) + monkeypatch.setattr(ms, "is_market_open", lambda: True) + m._update_last_ok_at = base + m._created_at = base + stale = mm._stale_sec_v2() + clock["t"] = base + timedelta(seconds=stale - 1) + assert await m.can_open_new_position() is True + clock["t"] = base + timedelta(seconds=stale + 1) + assert await m.can_open_new_position() is False + await m.record_update_success() + assert await m.can_open_new_position() is True + monkeypatch.setattr(ms, "is_market_open", lambda: False) + clock["t"] = base + timedelta(hours=20) + assert await m.can_open_new_position() is True + + +# ── 하드닝 라운드 2 ────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_r2_page_full_balance_does_not_mark_executed(env): + """잔고 응답이 페이지 한도에 도달하면 보이는 종목만 축소하고 executed 는 세우지 않는다.""" + _, make = env + n = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) + acct = make(_Account({f"{i:06d}": 100 for i in range(n)})) + m = mm.MDDManager() + await _arm(m) + await m.execute_reduction(_action(m)) + assert len(acct.sells) == n + assert not getattr(m, f"_reduction_executed_{m.reduction_applied}") + + +@pytest.mark.asyncio +async def test_r2_below_page_still_marks_executed(env): + _, make = env + n = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) + make(_Account({f"{i:06d}": 100 for i in range(n - 1)})) + m = mm.MDDManager() + await _arm(m) + await m.execute_reduction(_action(m)) + assert getattr(m, f"_reduction_executed_{m.reduction_applied}") + + +def test_r2_stale_threshold_exceeds_swing_cycle_budget(): + from app.services.swing import scheduler as sw + budget = (sw._DEFAULT_ENTRY_PHASE_TIMEOUT_SEC + sw._DEFAULT_EXIT_STEP_TIMEOUT_SEC + + sw._DEFAULT_BALANCE_TIMEOUT_SEC + 60.0) + assert mm._stale_sec_v2() > budget diff --git a/backend/tests/test_mdd_state_backup.py b/backend/tests/test_mdd_state_backup.py new file mode 100644 index 0000000..7c9e466 --- /dev/null +++ b/backend/tests/test_mdd_state_backup.py @@ -0,0 +1,166 @@ +import pytest + +from app.services.mdd_manager import MDDManager +from app.services import mdd_state_backup as B + + +class _RedisDown(MDDManager): + async def load_from_redis(self): + raise AssertionError("unused") + + +@pytest.mark.asyncio +async def test_file_fallback_preserves_peak(tmp_path, monkeypatch): + p = tmp_path / "m.json" + a = MDDManager() + a.peak_balance = 10_000_000.0 + a.is_paused = True + a.reduction_applied = "10pct" + a._reduction_executed_10pct = True + assert await B.save_mdd_backup_v2(a, p) + + b = MDDManager() + + async def _no_redis(): + return False + monkeypatch.setattr(b, "load_from_redis", _no_redis) + assert await B.restore_mdd_state_v2(b, p) == "file" + assert b.peak_balance == 10_000_000.0 and b.is_paused + assert b.reduction_applied == "10pct" and b._reduction_executed_10pct + # 복원된 peak 로 낙폭 계산 (재시드되지 않음) + r = await b.update(9_500_000.0) + assert round(r["drawdown"], 1) == -5.0 + + +@pytest.mark.asyncio +async def test_none_when_no_sources(tmp_path, monkeypatch): + b = MDDManager() + + async def _no_redis(): + return False + monkeypatch.setattr(b, "load_from_redis", _no_redis) + assert await B.restore_mdd_state_v2(b, tmp_path / "x.json") == "none" + + +@pytest.mark.asyncio +async def test_unseeded_does_not_overwrite_backup(tmp_path): + p = tmp_path / "m.json" + a = MDDManager() + a.peak_balance = 5.0 + await B.save_mdd_backup_v2(a, p) + assert not await B.save_mdd_backup_v2(MDDManager(), p) + assert '"peak_balance":5' in p.read_text().replace(" ", "") + + +@pytest.mark.asyncio +async def test_corrupt_file_returns_none(tmp_path, monkeypatch): + p = tmp_path / "m.json" + p.write_text("{bad") + b = MDDManager() + assert not await B.restore_mdd_from_backup_v2(b, p) + + +def _redis_loader(mgr, peak, **flags): + async def _load(): + mgr.peak_balance = peak + for k, v in flags.items(): + setattr(mgr, k, v) + return True + return _load + + +@pytest.mark.asyncio +async def test_stale_redis_loses_to_newer_file(tmp_path, monkeypatch): + p = tmp_path / "m.json" + a = MDDManager() + a.peak_balance = 12_000_000.0 + a._reduction_executed_10pct = True + assert await B.save_mdd_backup_v2(a, p) + b = MDDManager() + monkeypatch.setattr(b, "load_from_redis", _redis_loader(b, 10_000_000.0)) + assert await B.restore_mdd_state_v2(b, p) == "file" + assert b.peak_balance == 12_000_000.0 and b._reduction_executed_10pct + + +@pytest.mark.asyncio +async def test_equal_peak_prefers_more_conservative_flags(tmp_path, monkeypatch): + p = tmp_path / "m.json" + a = MDDManager() + a.peak_balance = 10_000_000.0 + a._reduction_executed_10pct = True + a.is_paused = True + await B.save_mdd_backup_v2(a, p) + b = MDDManager() + monkeypatch.setattr(b, "load_from_redis", _redis_loader(b, 10_000_000.0)) + assert await B.restore_mdd_state_v2(b, p) == "file" + assert b.is_paused and b._reduction_executed_10pct + + +@pytest.mark.asyncio +async def test_redis_wins_when_newer_or_file_missing(tmp_path, monkeypatch): + p = tmp_path / "m.json" + a = MDDManager() + a.peak_balance = 9_000_000.0 + await B.save_mdd_backup_v2(a, p) + b = MDDManager() + monkeypatch.setattr(b, "load_from_redis", _redis_loader(b, 10_000_000.0)) + assert await B.restore_mdd_state_v2(b, p) == "redis" + assert b.peak_balance == 10_000_000.0 + c = MDDManager() + monkeypatch.setattr(c, "load_from_redis", _redis_loader(c, 10_000_000.0)) + assert await B.restore_mdd_state_v2(c, tmp_path / "none.json") == "redis" + + +@pytest.mark.asyncio +async def test_rebased_lower_peak_in_redis_wins_by_saved_at(tmp_path, monkeypatch): + """reset 재기준으로 낮아진 Redis peak 를 옛(높은 peak) 파일이 되살리면 안 된다.""" + p = tmp_path / "m.json" + a = MDDManager() + a.peak_balance = 10_000_000.0 + a.is_paused = True + await B.save_mdd_backup_v2(a, p) + import json, time + file_at = json.loads(p.read_text())["saved_at"] + + b = MDDManager() + monkeypatch.setattr(b, "load_from_redis", _redis_loader(b, 8_000_000.0)) + + async def _r_at(): + return file_at + 5.0 + monkeypatch.setattr(B, "_read_redis_saved_at_v2", _r_at) + assert await B.restore_mdd_state_v2(b, p) == "redis" + assert b.peak_balance == 8_000_000.0 and not b.is_paused + + +@pytest.mark.asyncio +async def test_newer_file_wins_by_saved_at_even_if_lower_peak(tmp_path, monkeypatch): + p = tmp_path / "m.json" + a = MDDManager() + a.peak_balance = 7_000_000.0 + await B.save_mdd_backup_v2(a, p) + import json + file_at = json.loads(p.read_text())["saved_at"] + b = MDDManager() + monkeypatch.setattr(b, "load_from_redis", _redis_loader(b, 10_000_000.0)) + + async def _r_at(): + return file_at - 100.0 + monkeypatch.setattr(B, "_read_redis_saved_at_v2", _r_at) + assert await B.restore_mdd_state_v2(b, p) == "file" + assert b.peak_balance == 7_000_000.0 + + +@pytest.mark.asyncio +async def test_save_everywhere_stamps_same_saved_at(tmp_path, monkeypatch): + p = tmp_path / "m.json" + a = MDDManager() + a.peak_balance = 5_000_000.0 + seen = {} + + async def _w(key, state): + seen.update(state) + monkeypatch.setattr(a, "_write_state_v2", _w) + assert await B.save_mdd_state_everywhere_v2(a, p) + import json + assert seen["saved_at"] == json.loads(p.read_text())["saved_at"] + assert not await B.save_mdd_state_everywhere_v2(MDDManager(), p) diff --git a/backend/tests/test_mean_reversion.py b/backend/tests/test_mean_reversion.py index 8634183..60a6ab0 100644 --- a/backend/tests/test_mean_reversion.py +++ b/backend/tests/test_mean_reversion.py @@ -883,6 +883,75 @@ class TestCheckExitSignal: assert result.action == "SELL_ALL" assert "손절" in result.reason + # ── R4 2026-10-09: 1단계(분할 전)에서 전량 조건은 50% 분할로 처리된다 ────── + # 문서(docstring)가 "단계 무관 전량"이라 서술했으나 실제로는 분할 분기가 먼저 return 한다. + # 동작은 유지(백테스트 없이 청산 타이밍 변경 금지 — §8.1-5)하고 계약을 테스트로 고정한다. + # 적응형 오버레이는 예외 발생형 목으로 끊어 config(settings) 임계를 쓰게 한다. + + @staticmethod + def _block_adaptive(monkeypatch): + import app.services.adaptive_strategy as _adp_mod + + def _raise(*_a, **_k): + raise RuntimeError("adaptive blocked in test") + + monkeypatch.setattr(_adp_mod, "get_current_adaptive_params", _raise) + + @staticmethod + def _full_exit_kwargs(remaining_pct): + from app.core.config import settings + entry = 10000.0 + bb_upper = 10500.0 + # BB상단×임계 이상 + RSI ≥ 전량 임계 → 전량 조건 2개 모두 성립. 수익 ≥ 0.5%. + close = bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD + 10 + return dict( + regime=MarketRegime.BULL, + rsi=settings.MEAN_REVERSION_RSI_FULL_EXIT + 1.0, + close=close, + bb_upper=bb_upper, + bb_middle=10100.0, + bb_lower=9500.0, + entry_price=entry, + atr=50.0, # 손절선(≥ entry−2.5×ATR) 훨씬 아래 → 손절 미발동 + lowest_since_entry=9900.0, + remaining_pct=remaining_pct, + ) + + def test_full_exit_conditions_before_partial_are_split_r4(self, monkeypatch): + """remaining_pct=1.0 + 전량 조건(RSI≥full·BB상단) → SELL_PARTIAL 50% (현재 계약)""" + self._block_adaptive(monkeypatch) + kw = self._full_exit_kwargs(remaining_pct=1.0) + assert (kw["close"] - kw["entry_price"]) / kw["entry_price"] * 100 >= 0.5 + result = check_exit_signal(**kw) + assert result is not None + assert result.action == "SELL_PARTIAL" + assert result.sell_pct == 0.50 + + def test_full_exit_conditions_after_partial_sell_all_r4(self, monkeypatch): + """remaining_pct=0.50(1차 익절 후) + 같은 조건 → SELL_ALL, 사유에 RSI·BB상단 모두""" + self._block_adaptive(monkeypatch) + result = check_exit_signal(**self._full_exit_kwargs(remaining_pct=0.50)) + assert result is not None + assert result.action == "SELL_ALL" + assert result.sell_pct == 1.0 + assert "RSI(" in result.reason and "BB상단" in result.reason + + def test_full_exit_before_partial_below_min_profit_holds_r6(self, monkeypatch): + """[R6 2026-10-10] 수익 < 0.5% 이면 RSI/BB상단 전량 '익절'도 나가지 않는다. + (R4 판은 이 구간 SELL_ALL 을 고정했으나, 손실 구간 '익절'이 연패로 쌓이는 결함이었다.) + 손실 구간은 손절·시간손절이 맡는다.""" + self._block_adaptive(monkeypatch) + kw = self._full_exit_kwargs(remaining_pct=1.0) + kw["entry_price"] = kw["close"] # 수익 0% → 분할·전량 익절 모두 skip + kw["lowest_since_entry"] = kw["close"] - 10 + assert check_exit_signal(**kw) is None + + def test_docstring_no_longer_claims_stage_independent_r4(self): + """docstring 이 '단계 무관 전량'을 다시 주장하지 않도록 고정""" + doc = check_exit_signal.__doc__ or "" + assert "도달 (단계 무관)" not in doc + assert "단계 우선순위" in doc + # ═══════════════════════════════════════════════════════════════════════════ # 9. calculate_position_size diff --git a/backend/tests/test_ml_retrain_price_fallback.py b/backend/tests/test_ml_retrain_price_fallback.py new file mode 100644 index 0000000..20a6844 --- /dev/null +++ b/backend/tests/test_ml_retrain_price_fallback.py @@ -0,0 +1,120 @@ +from types import SimpleNamespace as NS + +from app.services.ml_retrain_pipeline import _resolve_trade_price_v2 + + +def test_executed_price_preferred(): + assert _resolve_trade_price_v2(NS(executed_price=100, total_amount=1, executed_quantity=1, order_price=5)) == 100.0 + + +def test_null_falls_back_to_amount_over_qty(): + assert _resolve_trade_price_v2(NS(executed_price=None, total_amount=1000, executed_quantity=10, order_price=5)) == 100.0 + + +def test_null_falls_back_to_order_price(): + assert _resolve_trade_price_v2(NS(executed_price=None, total_amount=None, executed_quantity=None, order_price=55)) == 55.0 + + +def test_all_missing_zero(): + assert _resolve_trade_price_v2(NS(executed_price=None, total_amount=None, executed_quantity=None, order_price=None)) == 0.0 + + +# ─── 라운드4: 성능 저하 감시 / 장중 재학습 방지 ─────────────────────────── +import asyncio +from datetime import datetime + +import pandas as pd +import pytest + +from app.services import ml_retrain_pipeline as mrp + + +def test_filled_statuses_include_completed(): + assert "COMPLETED" in mrp._FILLED_TRADE_STATUSES + + +def test_load_trade_history_filters_completed(monkeypatch): + captured = {} + + class _Res: + def scalars(self): + return self + + def all(self): + return [] + + class _Sess: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, stmt): + captured["sql"] = str(stmt.compile(compile_kwargs={"literal_binds": True})) + return _Res() + + monkeypatch.setattr(mrp, "AsyncSessionLocal", lambda: _Sess()) + asyncio.run(mrp._load_trade_history(months=1)) + assert "COMPLETED" in captured["sql"] + + +@pytest.mark.parametrize( + "dt,expected", + [ + (datetime(2026, 10, 7, 9, 30), False), # 수 장중 + (datetime(2026, 10, 7, 15, 0), False), + (datetime(2026, 10, 7, 20, 0), True), # 수 야간 + (datetime(2026, 10, 7, 3, 0), True), + (datetime(2026, 10, 10, 11, 0), True), # 토 + ], +) +def test_safe_window(dt, expected): + assert mrp._is_retrain_safe_window_v2(dt) is expected + + +def test_run_retrain_builds_features_in_thread(monkeypatch): + seen = {} + + async def fake_load(months=6): + return pd.DataFrame({"stock_code": ["A"], "close": [1.0]}) + + def fake_build(df): + import threading + seen["thread"] = threading.current_thread() + return [], [] + + monkeypatch.setattr(mrp, "_load_daily_prices", fake_load) + monkeypatch.setattr(mrp, "_build_training_lists_impl", fake_build) + import threading + res = asyncio.run(mrp.run_retrain(force=True)) + assert res["status"] == "skipped" + assert seen["thread"] is not threading.main_thread() + + +def test_scheduler_defers_retrain_during_market_hours(monkeypatch): + calls = {"retrain": 0} + + async def fake_check(): + return True, {"degraded": True} + + async def fake_retrain(*a, **k): + calls["retrain"] += 1 + return {"status": "completed"} + + class _FakeDT(datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 10, 7, 9, 45, tzinfo=tz) + + async def fake_sleep(sec): + if sec == 3600: + raise asyncio.CancelledError + + monkeypatch.setattr(mrp, "check_performance_degradation", fake_check) + monkeypatch.setattr(mrp, "run_retrain", fake_retrain) + monkeypatch.setattr(mrp, "datetime", _FakeDT) + monkeypatch.setattr(mrp.asyncio, "sleep", fake_sleep) + with pytest.raises(asyncio.CancelledError): + asyncio.run(mrp.ml_retrain_scheduler()) + assert calls["retrain"] == 0 diff --git a/backend/tests/test_mr_backtest_entry_deterministic_20261009.py b/backend/tests/test_mr_backtest_entry_deterministic_20261009.py new file mode 100644 index 0000000..74b7264 --- /dev/null +++ b/backend/tests/test_mr_backtest_entry_deterministic_20261009.py @@ -0,0 +1,173 @@ +"""하드닝 R2 (2026-10-09) — 백테스트 진입 판정 결정론화 회귀 테스트. + +결함: kr_backtest_engine 의 진입 판정이 라이브 check_buy_signal 을 호출해, 실행 시각의 +라이브 적응형 모드(get_current_adaptive_params)와 운영 DB(strategy_cfg)로 RSI 임계·%B 스케일이 +정해졌다. 수정: check_buy_signal_backtest_v2(params, ...) 는 둘 다 읽지 않고 params > settings. +""" +import ast +import os +from types import SimpleNamespace + +import pytest + +from app.core.config import settings +from app.services import mean_reversion_strategy as mrs +from app.services.mean_reversion_strategy import MarketRegime + +BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +class LiveStateRead(BaseException): + """백테스트 경로가 라이브 상태를 읽으면 발생 — except Exception 폴백에 삼켜지지 않게 BaseException.""" + + +def _boom(*a, **k): + raise LiveStateRead("백테스트 진입 판정이 라이브 상태(adaptive/strategy_cfg)를 읽었다") + + +@pytest.fixture +def forbid_live_state(monkeypatch): + import app.services.adaptive_strategy as adp + monkeypatch.setattr(adp, "get_current_adaptive_params", _boom) + monkeypatch.setattr(mrs.strategy_cfg, "get", _boom) + + +def _bull_kwargs(rsi, close, rsi_prev=None): + return dict( + regime=MarketRegime.BULL, rsi=rsi, close=close, + bb_upper=11000.0, bb_middle=10500.0, bb_lower=10000.0, + avg_volume_20d=1_000_000, current_volume=1_200_000, prev_volume=900_000, + is_reversal=False, rsi_prev=rsi_prev, + ) + + +def _bear_kwargs(rsi, volume_mult, reversal=True, rsi_prev=None): + return dict( + regime=MarketRegime.BEAR, rsi=rsi, close=10100.0, # %B = 0.10 + bb_upper=11000.0, bb_middle=10500.0, bb_lower=10000.0, + avg_volume_20d=1_000_000, current_volume=int(1_000_000 * volume_mult), prev_volume=900_000, + is_reversal=reversal, rsi_prev=rsi_prev, + ) + + +# ── 1. 라이브 상태를 전혀 읽지 않는다 ──────────────────────────────────────── +def test_backtest_entry_never_reads_adaptive_or_strategy_cfg(forbid_live_state): + thr = float(settings.MEAN_REVERSION_RSI_THRESHOLD) + bear_thr = float(settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD) + # BULL 발화 / BULL 미발화 / BEAR 발화 / BEAR v2 경로 — 어느 것도 라이브 상태를 읽지 않아야 한다 + assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr - 20, 10100.0)) is not None + assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr + 1, 10100.0)) is None + vm = float(settings.MEAN_REVERSION_BEAR_VOLUME_MULT) + 0.5 + assert mrs.check_buy_signal_backtest_v2(None, **_bear_kwargs(bear_thr - 1, vm)) is not None + mrs.check_buy_signal_backtest_v2({"bear_v2_enabled": True}, **_bear_kwargs(bear_thr - 1, vm)) + + +def test_live_check_buy_signal_still_reads_live_state(forbid_live_state): + """대조군: 라이브 함수는 여전히 adaptive/strategy_cfg 를 읽는다(§0 우선순위 유지).""" + with pytest.raises(LiveStateRead): + mrs.check_buy_signal(**_bull_kwargs(float(settings.MEAN_REVERSION_RSI_THRESHOLD) - 20, 10100.0)) + + +# ── 2. 같은 입력 → 라이브 모드와 무관하게 같은 결과 ────────────────────────── +@pytest.mark.parametrize("live_mode", [ + SimpleNamespace(fallback_used=False, rsi_entry=50.0, bb_pct_b=0.39, bear_rsi_entry=40.0), # Aggressive 류 + SimpleNamespace(fallback_used=False, rsi_entry=38.0, bb_pct_b=0.25, bear_rsi_entry=25.0), # Defensive 류 + SimpleNamespace(fallback_used=True, rsi_entry=99.0, bb_pct_b=0.99, bear_rsi_entry=99.0), +]) +def test_backtest_entry_independent_of_live_mode(monkeypatch, live_mode): + import app.services.adaptive_strategy as adp + monkeypatch.setattr(adp, "get_current_adaptive_params", lambda: live_mode) + monkeypatch.setattr(mrs.strategy_cfg, "get", lambda k, *a, **kw: 1.0) # 운영 DB 의 이상값 흉내 + thr = float(settings.MEAN_REVERSION_RSI_THRESHOLD) + # thr+2 는 settings 기준 미발화 — 라이브 Aggressive(50) 가 새면 발화한다 + assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr + 2, 10100.0)) is None + # 기본 스케일 1.0: RSI 30~임계 구간 %B 상한 0.20 → %B 0.25 는 미발화 (스케일 1.3 이 새면 발화) + assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr - 5, 10250.0)) is None + r = mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr - 5, 10150.0)) + assert r is not None and f"<{thr:.0f}" in r["description"] + + +# ── 3. params 주입이 반영된다 (모드별 백테스트 가능) ──────────────────────── +def test_params_rsi_entry_and_bb_pct_b_injected(forbid_live_state): + thr = float(settings.MEAN_REVERSION_RSI_THRESHOLD) + rsi = thr + 2 + assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(rsi, 10100.0)) is None + r = mrs.check_buy_signal_backtest_v2({"rsi_entry": thr + 4}, **_bull_kwargs(rsi, 10100.0)) + assert r is not None and f"<{thr + 4:.0f}" in r["description"] + # bb_pct_b=0.39 → 스케일 1.3(상한) → %B 상한 0.26 → %B 0.25 발화 + assert mrs.check_buy_signal_backtest_v2({}, **_bull_kwargs(thr - 5, 10250.0)) is None + assert mrs.check_buy_signal_backtest_v2({"bb_pct_b": 0.39}, **_bull_kwargs(thr - 5, 10250.0)) is not None + # 상한 1.3 유지: bb_pct_b=0.9 여도 0.26 을 넘는 %B 0.30 은 미발화 + assert mrs.check_buy_signal_backtest_v2({"bb_pct_b": 0.9}, **_bull_kwargs(thr - 5, 10300.0)) is None + + +def test_params_bear_threshold_and_volume_injected(forbid_live_state): + bear_thr = float(settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD) + base_vm = float(settings.MEAN_REVERSION_BEAR_VOLUME_MULT) + ok_vm = base_vm + 0.5 + assert mrs.check_buy_signal_backtest_v2({}, **_bear_kwargs(bear_thr + 1, ok_vm)) is None + assert mrs.check_buy_signal_backtest_v2( + {"bear_rsi_entry": bear_thr + 3}, **_bear_kwargs(bear_thr + 1, ok_vm)) is not None + # 거래량 배수 상향 주입 시 미발화 + assert mrs.check_buy_signal_backtest_v2( + {"bear_volume_mult": ok_vm + 1.0}, **_bear_kwargs(bear_thr - 1, ok_vm)) is None + + +@pytest.mark.parametrize("bad", ["abc", float("nan"), float("inf"), -5.0, 0.0, 150.0]) +def test_invalid_rsi_entry_falls_back_to_settings(forbid_live_state, bad): + thr = float(settings.MEAN_REVERSION_RSI_THRESHOLD) + # 비정상 값은 settings 로 폴백 — 150 이 그대로 쓰이면 모든 봉이 진입한다 + assert mrs.check_buy_signal_backtest_v2({"rsi_entry": bad}, **_bull_kwargs(thr + 2, 10100.0)) is None + + +# ── 4. 라이브 함수와 동일 로직 (출처만 다르다) ─────────────────────────────── +def test_live_and_backtest_parity_for_same_effective_params(monkeypatch): + import app.services.adaptive_strategy as adp + mode = SimpleNamespace(fallback_used=False, rsi_entry=50.0, bb_pct_b=0.36, bear_rsi_entry=30.0) + monkeypatch.setattr(adp, "get_current_adaptive_params", lambda: mode) + monkeypatch.setattr(mrs.strategy_cfg, "get", lambda k, *a, **kw: None) + params = {"rsi_entry": 50.0, "bb_pct_b": 0.36, "bear_rsi_entry": 30.0} + cases = [_bull_kwargs(r, c, rp) for r in (22.0, 28.0, 45.0, 49.0, 51.0) + for c in (10050.0, 10250.0, 10400.0) for rp in (None, 10.0, 60.0)] + cases += [_bear_kwargs(r, vm, rev) for r in (20.0, 29.0, 31.0) + for vm in (1.0, 2.5) for rev in (True, False)] + for kw in cases: + assert mrs.check_buy_signal(**kw) == mrs.check_buy_signal_backtest_v2(params, **kw), kw + + +# ── 5. 엔진 배선: kr_backtest_engine 은 라이브 check_buy_signal 을 호출하지 않는다 ── +def test_kr_backtest_engine_uses_backtest_entry(): + p = os.path.join(BACKEND_DIR, "app", "services", "kr_backtest_engine.py") + with open(p, encoding="utf-8") as f: + tree = ast.parse(f.read()) + called = set() + for node in ast.walk(tree): + if isinstance(node, ast.Call): + fn = node.func + called.add(fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", None)) + assert "check_buy_signal" not in called + assert "check_buy_signal_backtest_v2" in called + assert "get_current_adaptive_params" not in called + + +def test_kr_backtest_on_bar_passes_params(monkeypatch): + import app.services.kr_backtest_engine as eng + captured = [] + + def spy(params, **kw): + captured.append(params) + return None + monkeypatch.setattr(eng, "check_buy_signal_backtest_v2", spy) + monkeypatch.setattr(eng, "calculate_rsi", lambda c, p=14: 30.0) + monkeypatch.setattr(eng, "calculate_bollinger", lambda c, p=20: (110.0, 100.0, 90.0)) + from datetime import date, timedelta + s = eng.MeanReversionBacktest() + p = s.default_params() + p["rsi_entry"] = 41.0 + n = 80 + d0 = date(2025, 1, 2) + bars = [eng.BarData(trade_date=d0 + timedelta(days=i), open_price=10000 + i, high_price=10100 + i, + low_price=9900 + i, close_price=10000 + i, volume=1000, stock_code="A") + for i in range(n)] + s.on_bar(n - 1, {"A": bars}, {}, 1e6, 1e6, p) + assert captured and captured[0] is p and captured[0]["rsi_entry"] == 41.0 diff --git a/backend/tests/test_mr_full_exit_profit_guard_r6_20261010.py b/backend/tests/test_mr_full_exit_profit_guard_r6_20261010.py new file mode 100644 index 0000000..10f2614 --- /dev/null +++ b/backend/tests/test_mr_full_exit_profit_guard_r6_20261010.py @@ -0,0 +1,113 @@ +"""[R6 2026-10-10] 평균회귀 전량 '익절'(RSI≥full / BB상단) 최소수익 가드 회귀 테스트. + +결함: check_exit_signal 3단계의 RSI≥rsi_full_exit / BB상단×임계 조건에 수익 가드가 없어 +손실 구간에서도 '전량청산'이 나가고 loss_tracker 연패로 쌓였다 +(운영 trade_history id 29: 032830 '전량청산: BB상단 도달', -0.42%). +수정: 두 조건에 1차 익절과 같은 _MIN_PROFIT_FOR_EXIT(0.5%) 가드. 트레일링·손절은 가드 없음. +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import app.services.mean_reversion_strategy as mr +from app.core.config import settings + + + + +def _cfg_empty(): + m = MagicMock() + m.get.side_effect = lambda key, default=None: default + return m + + +@pytest.fixture(autouse=True) +def deterministic(): + """적응형 오버레이 없음 + strategy_cfg 비어 있음 → settings 값만 사용.""" + with patch("app.services.adaptive_strategy.get_current_adaptive_params", + return_value=SimpleNamespace(fallback_used=True)), \ + patch.object(mr, "strategy_cfg", _cfg_empty()): + yield + + +ENTRY = 10000.0 +ATR = 200.0 + + +def _hot_rsi(): + return float(settings.MEAN_REVERSION_RSI_FULL_EXIT) + 5.0 + + +def _bb_upper_touched_by(close): + # close >= bb_upper × 임계 가 되도록 + return close / float(settings.MEAN_REVERSION_BB_UPPER_THRESHOLD) - 1.0 + + +def _sig(close, remaining_pct, trailing_high=None, rsi=None, bb_middle=None): + return mr.check_exit_signal( + regime=mr.MarketRegime.BULL, + rsi=_hot_rsi() if rsi is None else rsi, + close=close, + bb_upper=_bb_upper_touched_by(close), + bb_middle=(close + 1000.0) if bb_middle is None else bb_middle, + bb_lower=close - 2000.0, + entry_price=ENTRY, atr=ATR, lowest_since_entry=close, + remaining_pct=remaining_pct, trailing_high=trailing_high, + ) + + +def _stop_price(): + sl_pct, mult = mr.resolve_swing_stop_params_v2(ATR, ENTRY) + return max(ENTRY - mult * ATR, ENTRY * (1 + sl_pct / 100)) + + +def _loss_close(): + close = ENTRY * 0.996 # -0.4% (운영 id 29 와 같은 크기) + assert close > _stop_price() # 손절선 위 + return close + + +@pytest.mark.parametrize("remaining_pct", [1.0, 0.50]) +def test_loss_zone_rsi_and_bb_upper_do_not_full_exit(remaining_pct): + assert _sig(_loss_close(), remaining_pct) is None + + +def test_small_profit_below_guard_does_not_full_exit(): + close = ENTRY * 1.003 # +0.3% < 0.5% + assert _sig(close, 0.50) is None + assert _sig(close, 1.0) is None + + +def test_profit_above_guard_after_partial_still_full_exits(): + close = ENTRY * 1.03 + sig = _sig(close, 0.50) + assert sig is not None and sig.action == "SELL_ALL" + assert "RSI(" in sig.reason and "BB상단(" in sig.reason + + +def test_profit_above_guard_before_partial_is_partial(): + """단계 우선순위 불변: 1차 익절 전 + 수익≥0.5% 는 여전히 50% 분할.""" + sig = _sig(ENTRY * 1.03, 1.0, bb_middle=ENTRY) + assert sig is not None and sig.action == "SELL_PARTIAL" + + +def test_trailing_not_guarded_in_loss_zone(): + close = _loss_close() + high = ENTRY * 1.05 + trail = high - mr.resolve_swing_trailing_atr_mult_v2() * ATR + assert close < trail + sig = _sig(close, 0.50, trailing_high=high, rsi=50.0) + assert sig is not None and sig.action == "SELL_ALL" and "트레일링" in sig.reason + assert "BB상단(" not in sig.reason + + +def test_stop_loss_unaffected(): + close = _stop_price() - 1.0 + sig = _sig(close, 1.0) + assert sig is not None and sig.action == "SELL_ALL" and sig.reason.startswith("손절") diff --git a/backend/tests/test_naver_close_quality_r4.py b/backend/tests/test_naver_close_quality_r4.py new file mode 100644 index 0000000..740a9ae --- /dev/null +++ b/backend/tests/test_naver_close_quality_r4.py @@ -0,0 +1,97 @@ +import asyncio +import httpx +import pytest +from app.services import naver_finance as nf + + +@pytest.fixture(autouse=True) +def _reset(): + for k in nf._naver_close_stats: + nf._naver_close_stats[k] = 0 + + +def test_tick_validity(): + assert nf.is_krx_tick_valid(274500) # 500 단위 아님 -> 아래 확인 + assert not nf.is_krx_tick_valid(274750) + assert not nf.is_krx_tick_valid(1837500) + assert nf.is_krx_tick_valid(1837000) + assert not nf.is_krx_tick_valid(30075) + assert nf.is_krx_tick_valid(30050) + assert not nf.is_krx_tick_valid(0) + + +def test_annotate_keeps_values_and_counts(): + rows = [{"stck_clpr": "274750"}, {"stck_clpr": "274500"}] + out = nf.annotate_naver_bars_v2(rows) + assert out[0]["stck_clpr"] == "274750" and out[0]["krx_tick_ok"] is False + assert out[1]["krx_tick_ok"] is True + q = nf.get_naver_close_quality() + assert q["checked"] == 2 and q["off_tick"] == 1 and q["off_tick_ratio"] == 0.5 + + +def test_etf_skipped(): + rows = [{"stck_clpr": "10005"}] + nf.annotate_naver_bars_v2(rows, is_etf=True) + assert "krx_tick_ok" not in rows[0] + + +def test_compare_with_krx(): + nv = [{"stck_bsop_date": "20260929", "stck_clpr": "318000"}, + {"stck_bsop_date": "20260930", "stck_clpr": "100"}] + krx = [{"stck_bsop_date": "20260929", "stck_clpr": "315500"}] + r = nf.compare_naver_with_krx_v2(nv, krx) + assert r == {"compared": 1, "mismatch": 1} + assert nv[0]["krx_close_mismatch"] is True + assert "krx_close_mismatch" not in nv[1] + assert nf.get_naver_close_quality()["krx_mismatch_ratio"] == 1.0 + + +def test_chart_fn_annotates_without_network(): + body = '[["날짜","시가","고가","저가","종가","거래량"],\n["20260930","1","1","1","274750","10"],\n]' + def handler(req): + return httpx.Response(200, text=body) + async def run(): + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as c: + return await nf.get_naver_daily_chart("005930", days=5, client=c) + res = asyncio.run(run()) + assert res[0]["stck_clpr"] == "274750" and res[0]["krx_tick_ok"] is False + + +def _fetch(code, **kw): + body = '[["날짜","시가","고가","저가","종가","거래량"],\n["20260930","1","1","1","109530","10"],\n]' + def handler(req): + return httpx.Response(200, text=body) + async def run(): + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as c: + return await nf.get_naver_daily_chart_v2(code, days=5, client=c, **kw) + return asyncio.run(run()) + + +def test_benchmark_and_registered_etf_not_counted(): + res = _fetch("069500") + assert "krx_tick_ok" not in res[0] + nf.register_etf_codes_v2(["069660"]) + try: + res = asyncio.run(_wrap("069660")) + assert "krx_tick_ok" not in res[0] + finally: + nf._registered_etf_codes.discard("069660") + assert nf.get_naver_close_quality()["checked"] == 0 + + +async def _wrap(code): + body = '[["날짜","시가","고가","저가","종가","거래량"],\n["20260930","1","1","1","109530","10"],\n]' + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text=body))) as c: + return await nf.get_naver_daily_chart("069660", days=5, client=c) + + +def test_explicit_is_etf_and_stock_counted(): + assert "krx_tick_ok" not in _fetch("005930", is_etf=True)[0] + assert _fetch("005930")[0]["krx_tick_ok"] is False + assert nf.get_naver_close_quality()["checked"] == 1 + + +def test_quality_alert(): + assert nf.naver_close_quality_alert_v2() is None + nf._naver_close_stats.update(krx_compared=100, krx_mismatch=30) + assert "30/100" in nf.naver_close_quality_alert_v2() diff --git a/backend/tests/test_news_sentiment_round3_20261009.py b/backend/tests/test_news_sentiment_round3_20261009.py new file mode 100644 index 0000000..7f7275b --- /dev/null +++ b/backend/tests/test_news_sentiment_round3_20261009.py @@ -0,0 +1,81 @@ +"""news_sentiment 라운드3: UTC 기사 나이 정규화, 0건 비캐시, 연속 0건 경보.""" +import asyncio +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock + +import pytest + +from app.core.config import settings +from app.core.timeutil import now_kst +from app.services import news_sentiment as ns + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch): + for st in ns._health.values(): + st.update(consecutive_empty=0, last_success_at=None, alerted=False) + # 네트워크/Redis/DB 는 호출 즉시 예외 (목 누락 시 거짓 통과 방지) + def boom(*a, **k): + raise AssertionError("unmocked external call") + monkeypatch.setattr(ns, "get_json", AsyncMock(return_value=None)) + monkeypatch.setattr(ns, "_save_news_to_db", boom) + yield + + +def test_utc_article_age_is_normalized_to_kst(): + # 16시간 전 UTC 기사 -> 24h 이내 가중치 1.0 이어야 한다(구버그: 25h -> 0.6) + pub = (datetime.now(timezone.utc) - timedelta(hours=16)).isoformat().replace("+00:00", "Z") + recent = {"sentiment_score": -1.0, "published_at": pub} + fresh = {"sentiment_score": 1.0, "published_at": now_kst()} + # 둘 다 가중 1.0 이면 평균 0.0, 구버그면 (-0.6+1)/1.6=0.25 + assert ns._compute_aggregate_score([recent, fresh]) == 0.0 + + +def test_aware_datetime_object_normalized(): + aware = datetime.now(timezone.utc) - timedelta(hours=16) + out = ns._to_kst_naive(aware) + assert out.tzinfo is None + assert abs((now_kst() - out).total_seconds() - 16 * 3600) < 5 + + +def test_empty_stock_news_not_cached_and_counted(monkeypatch): + setex = AsyncMock() + monkeypatch.setattr(ns, "setex_json", setex) + monkeypatch.setattr(ns, "collect_stock_news", AsyncMock(return_value=[])) + assert asyncio.run(ns.get_stock_news_score("005930")) == 0.0 + keys = [c.args[0] for c in setex.call_args_list] + assert "news_score:stock:005930" not in keys # 0.0 점수 캐시 금지 + assert ns._health["stock"]["consecutive_empty"] == 1 + + +def test_consecutive_empty_alerts_once(monkeypatch, caplog): + from app.services import telegram_notifier as tn + send = AsyncMock(return_value=True) + monkeypatch.setattr(tn.telegram_notifier, "send_message", send) + thr = int(getattr(settings, "NEWS_EMPTY_ALERT_THRESHOLD", ns._EMPTY_ALERT_DEFAULT)) + + async def run(): + for _ in range(thr + 3): + await ns._record_collect_result("market", 0) + with caplog.at_level("ERROR"): + asyncio.run(run()) + assert send.await_count == 1 + assert any(r.levelname == "ERROR" for r in caplog.records) + + +def test_success_resets_and_records_time(): + asyncio.run(ns._record_collect_result("stock", 0)) + asyncio.run(ns._record_collect_result("stock", 3)) + h = ns.get_news_collect_health()["stock"] + assert h["consecutive_empty"] == 0 and h["last_success_at"] + + +def test_stock_news_with_articles_is_cached(monkeypatch): + setex = AsyncMock() + monkeypatch.setattr(ns, "setex_json", setex) + monkeypatch.setattr(ns, "_save_news_to_db", AsyncMock()) + arts = [{"title": "어닝쇼크", "sentiment_score": -0.9, "published_at": now_kst()}] + monkeypatch.setattr(ns, "collect_stock_news", AsyncMock(return_value=arts)) + score = asyncio.run(ns.get_stock_news_score("005930")) + assert score <= settings.NEWS_BLOCK_SCORE + setex.assert_awaited_with("news_score:stock:005930", settings.NEWS_CACHE_TTL, score) diff --git a/backend/tests/test_news_sentiment_round8_market_json.py b/backend/tests/test_news_sentiment_round8_market_json.py new file mode 100644 index 0000000..adbb90f --- /dev/null +++ b/backend/tests/test_news_sentiment_round8_market_json.py @@ -0,0 +1,89 @@ +"""news_sentiment 라운드8: 시장 뉴스 JSON 1차 경로, HTML 폴백, 연속 0건 기록, 시간대 규약.""" +import asyncio +from datetime import datetime +from unittest.mock import AsyncMock + +import pytest + +from app.core.config import settings +from app.services import news_sentiment as ns + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch): + for st in ns._health.values(): + st.update(consecutive_empty=0, last_success_at=None, alerted=False) + monkeypatch.setattr(ns, "get_json", AsyncMock(return_value=None)) + monkeypatch.setattr(ns, "setex_json", AsyncMock()) + monkeypatch.setattr(ns, "_save_news_to_db", AsyncMock()) + monkeypatch.setattr(ns.settings, "NEWS_ENABLED", True) + yield + + +def _boom(*a, **k): + raise AssertionError("unmocked external call") + + +def test_market_json_primary_path_used_without_html(monkeypatch): + items = [{"title": "삼성전자 사상최대 실적 발표", "officeId": "001", "articleId": "1", + "datetime": "202610091530"}] + monkeypatch.setattr(ns, "_collect_market_news_json", AsyncMock(return_value=[ + {"title": items[0]["title"], "sentiment_score": 0.5, "published_at": datetime(2026, 10, 9, 15, 30)}])) + monkeypatch.setattr(ns, "_collect_market_news_html", _boom) + out = asyncio.run(ns.collect_market_news(limit=5)) + assert len(out) == 1 + + +def test_market_json_failure_falls_back_to_html(monkeypatch): + monkeypatch.setattr(ns, "_collect_market_news_json", AsyncMock(side_effect=RuntimeError("x"))) + html = AsyncMock(return_value=[{"title": "t"}]) + monkeypatch.setattr(ns, "_collect_market_news_html", html) + assert asyncio.run(ns.collect_market_news(limit=5)) == [{"title": "t"}] + html.assert_awaited_once() + + +def test_market_json_empty_falls_back_to_html(monkeypatch): + monkeypatch.setattr(ns, "_collect_market_news_json", AsyncMock(return_value=[])) + html = AsyncMock(return_value=[]) + monkeypatch.setattr(ns, "_collect_market_news_html", html) + assert asyncio.run(ns.collect_market_news(limit=settings.NEWS_MAX_ARTICLES)) == [] + html.assert_awaited_once() + + +def test_extract_items_handles_shapes(): + assert ns._extract_news_items([{"title": "a"}]) == [{"title": "a"}] + assert ns._extract_news_items({"items": [{"items": [{"title": "b"}]}]}) == [{"title": "b"}] + assert ns._extract_news_items({"newsList": [{"title": "c"}]}) == [{"title": "c"}] + assert ns._extract_news_items("garbage") == [] + + +def test_json_datetime_kst_semantics(): + assert ns._parse_naver_json_datetime("202610091530") == datetime(2026, 10, 9, 15, 30) + # 오프셋 명시 ISO 는 KST 로 변환 (UTC 06:30 -> KST 15:30) + assert ns._parse_naver_json_datetime("2026-10-09T06:30:00Z") == datetime(2026, 10, 9, 15, 30) + + +def test_market_empty_recorded_and_negative_cached(monkeypatch): + monkeypatch.setattr(ns, "collect_market_news", AsyncMock(return_value=[])) + monkeypatch.setattr(ns, "collect_newsapi_news", AsyncMock(return_value=[])) + assert asyncio.run(ns.get_market_news_score()) == 0.0 + assert ns._health["market"]["consecutive_empty"] == 1 + keys = [c.args[0] for c in ns.setex_json.call_args_list] + assert "news_score:market" not in keys + assert "news_score:market_empty" in keys + + +def test_market_empty_negative_cache_skips_recrawl(monkeypatch): + async def gj(key): + return 1 if key == "news_score:market_empty" else None + monkeypatch.setattr(ns, "get_json", gj) + monkeypatch.setattr(ns, "collect_market_news", _boom) + assert asyncio.run(ns.get_market_news_score()) == 0.0 + + +def test_market_success_records_and_caches(monkeypatch): + art = {"title": "x", "sentiment_score": -0.6, "published_at": ns.now_kst()} + monkeypatch.setattr(ns, "collect_market_news", AsyncMock(return_value=[art])) + monkeypatch.setattr(ns, "collect_newsapi_news", AsyncMock(return_value=[])) + assert asyncio.run(ns.get_market_news_score()) == -0.6 + assert ns._health["market"]["last_success_at"] is not None diff --git a/backend/tests/test_p1_audit_fixes.py b/backend/tests/test_p1_audit_fixes.py index cc5f307..61ce967 100644 --- a/backend/tests/test_p1_audit_fixes.py +++ b/backend/tests/test_p1_audit_fixes.py @@ -64,7 +64,7 @@ async def test_mdd_reduction_reflects_quantities(): await m._sync_active_trade_after_reduction("005930", quantity=10, remaining_qty=7) assert len(captured) == 1, "축소 반영 payload 가 큐잉되지 않음" assert captured[0].get("quantity") == 7, "ActiveTrade 잔량 미반영 → 다음 청산이 과다매도" - assert captured[0]["remaining_pct_multiply"] == pytest.approx(0.7) + assert "remaining_pct_multiply" not in captured[0], "익절 단계(remaining_pct) 오염 금지" # (2) DT 포지션 병존 → DT 행은 건드리지 않고(비-DT 필터) 스윙 잔량만 반영 captured.clear() @@ -154,3 +154,27 @@ def test_sync_dt_tagging_applies_to_domestic_and_survives_missing_output1(): assert 'return "daytrading"' in pure_src and "quantity <= _dt_held" in pure_src # 정의가 output1 블록보다 앞이어야 NameError 가 없다 assert src.index("_dt_codes = set(_dt_qty)") < src.index('if "output1" in balance_data') + + +@pytest.mark.asyncio +async def test_mdd_reduction_never_touches_remaining_pct_stage(): + """[하드닝 R3] MDD 축소 payload 는 remaining_pct(익절 단계)를 건드리지 않는다. + + ×0.7 이 곱해지면 모멘텀 1차 익절 게이트(remaining>0.75)가 영구 폐쇄된다. + """ + from app.services import mdd_manager as m + + captured: list = [] + + async def _cap(payload): + captured.append(payload) + + for dt in (0, 3): + captured.clear() + with patch("app.services.db_worker.enqueue_active_trade_upsert", _cap), \ + patch.object(m, "_dt_position_qty", lambda _c, _d=dt: _d): + await m._sync_active_trade_after_reduction("005930", quantity=10, remaining_qty=7) + assert len(captured) == 1 + assert "remaining_pct_multiply" not in captured[0] + assert "remaining_pct" not in captured[0] + assert captured[0]["quantity"] == 7 diff --git a/backend/tests/test_p3_audit_fixes.py b/backend/tests/test_p3_audit_fixes.py index 6d859ad..9f04d52 100644 --- a/backend/tests/test_p3_audit_fixes.py +++ b/backend/tests/test_p3_audit_fixes.py @@ -101,7 +101,11 @@ def test_sell_closes_active_trade_with_real_strategy_name(): """전량청산이 strategy_type('swing')으로 닫으면 MOMENTUM_BREAKOUT 행이 안 닫힌다.""" from app.services import trade_executor as te src = inspect.getsource(te._execute_sell) - assert "_at_row.strategy_name if _at_row else None" in src + # [하드닝 R8] 역추적이 _resolve_sell_active_trade_target_v2 로 분리됨(불변식 동일) + assert "_resolve_sell_active_trade_target_v2(" in src + assert "_row.strategy_name if _row else None" in inspect.getsource( + te._resolve_sell_active_trade_target_v2 + ) assert "enqueue_close_active_trade(stock_code, strategy_name=_at_name)" in src diff --git a/backend/tests/test_portfolio_cache_bridge.py b/backend/tests/test_portfolio_cache_bridge.py index 1e764f7..865ebf6 100644 --- a/backend/tests/test_portfolio_cache_bridge.py +++ b/backend/tests/test_portfolio_cache_bridge.py @@ -10,6 +10,29 @@ from app.models.models import Portfolio from app.services import portfolio_cache_bridge as bridge +class _DBBlocked(BaseException): + """실 DB 세션 시도 차단 (BaseException: 프로덕션 except Exception 에 삼켜지지 않게).""" + + +def _blocked_session(*a, **k): + raise _DBBlocked("테스트에서 app.core.database.AsyncSessionLocal 직접 사용 차단") + + +@pytest.fixture(autouse=True) +def _block_real_db(monkeypatch): + monkeypatch.setattr("app.core.database.AsyncSessionLocal", _blocked_session) + # DB 수량은 명시 주입 (기본: Portfolio 행 없음) + monkeypatch.setattr(bridge, "_db_quantity", AsyncMock(return_value=None)) + + +@pytest.mark.asyncio +async def test_real_db_session_is_blocked(): + """격리 회귀: 차단 픽스처 하에서 원본 조회 경로가 실 세션을 열면 즉시 예외.""" + from app.core.database import AsyncSessionLocal + with pytest.raises(_DBBlocked): + AsyncSessionLocal() + + @pytest.fixture(autouse=True) def _reset_cache(): portfolio_cache.holdings = [] diff --git a/backend/tests/test_portfolio_hardening_r1.py b/backend/tests/test_portfolio_hardening_r1.py new file mode 100644 index 0000000..4b2bf76 --- /dev/null +++ b/backend/tests/test_portfolio_hardening_r1.py @@ -0,0 +1,198 @@ +"""하드닝 R1: 팬텀 ActiveTrade close 유예 / FX 폴백 총자산 제외 / 상태 집합.""" +import asyncio +from datetime import datetime, timedelta +from types import SimpleNamespace + +import pytest + +from app.api.endpoints import portfolio as pf +from app.core.config import settings + + +@pytest.fixture(autouse=True) +def _reset(): + pf._phantom_state_reset() + yield + pf._phantom_state_reset() + + +NOW = datetime(2026, 10, 9, 10, 0, 0) + + +def _row(age_sec, qty=5): + ts = NOW - timedelta(seconds=age_sec) + return SimpleNamespace(status="active", quantity=qty, created_at=ts, updated_at=ts) + + +def _run(rows, recent): + return pf._reconcile_active_trade_quantities( + {"005930": rows}, {}, {"005930": "swing"}, {"005930": 0}, + phantom_guard=True, recent_trade_codes=recent, now=NOW, + ) + + +def test_new_row_not_closed(): + r = _row(5) + acts = _run([r], set()) + assert r.status == "active" and acts[0]["action"] == "close_deferred" + + +def test_recent_trade_defers(): + r = _row(99999) + _run([r], {"005930"}) + assert r.status == "active" + + +def test_recent_trades_unknown_defers(): + r = _row(99999) + _run([r], None) + assert r.status == "active" + + +def test_closes_only_after_two_consecutive_misses(): + r = _row(99999) + _run([r], set()) + assert r.status == "active" + acts = _run([r], set()) + assert r.status == "closed" and acts[0]["action"] == "closed" + + +def test_miss_counter_reset_when_held(): + r = _row(99999) + _run([r], set()) + pf._reconcile_active_trade_quantities( + {"005930": [r]}, {"005930": 5}, {"005930": "swing"}, {"005930": 5}, + phantom_guard=True, recent_trade_codes=set(), now=NOW) + _run([r], set()) + assert r.status == "active" + + +def test_legacy_call_unchanged(): + r = _row(1) + acts = pf._reconcile_active_trade_quantities({"005930": [r]}, {}, {}, None) + assert r.status == "closed" and acts[0]["action"] == "closed" + + +def test_inherit_helper_failure_returns_none(): + class Boom: + async def execute(self, *_a, **_k): + raise RuntimeError("db down") + assert asyncio.run(pf._recent_closed_non_dt_row_v2(Boom(), "005930", NOW)) is None + + +def test_config_default_fx_excluded_from_total_assets(monkeypatch): + monkeypatch.setattr(pf, "_last_good_fx_rate", 0.0) + monkeypatch.setattr(settings, "US_DEFAULT_FX_RATE", 1350.0, raising=False) + cache = pf.PortfolioCache() + cache.holdings = [SimpleNamespace( + stock_code="AAPL", market="NASD", quantity=10, average_price=100, current_price=200, + currency_code="USD", exchange_code="NASD", stock_name="A", profit_loss=0, + profit_loss_rate=0, strategy_type="us_swing")] + bal = {"rt_cd": "0", "output1": [], "output2": [{"tot_evlu_amt": "10000000", + "dnca_tot_amt": "10000000", "prvs_rcdl_excc_amt": "10000000"}]} + asyncio.run(cache.cache_summary_v2(bal, {})) + s = cache.summary + assert s["exchange_rate_source"] == "config_default" + assert s["exchange_rate_estimate"] == 1350.0 + assert s["exchange_rate_unavailable"] is True + assert s["total_assets"] == pytest.approx(10000000) + + +def test_realized_status_superset(): + from app.core.trade_status import REALIZED_PNL_STATUSES + assert {"COMPLETED", "FILLED", "EXECUTED"} <= set(REALIZED_PNL_STATUSES) + assert "PENDING" not in REALIZED_PNL_STATUSES + + +# ---- R4: 재시딩 승계 표식 / 실청산 구분 ------------------------------------- +from app.services.swing import exit_service as _xs + + +class _Res: + def __init__(self, rows): + self._rows = rows + + def all(self): + return self._rows + + +class _DB: + def __init__(self, rows=None, boom=False): + self.rows, self.boom = rows or [], boom + + async def execute(self, *_a, **_k): + if self.boom: + raise RuntimeError("db down") + return _Res(self.rows) + + +def _closed(qty=5, ep=10000.0): + return SimpleNamespace(id=7, stock_code="005930", quantity=qty, entry_price=ep, + updated_at=NOW, created_at=NOW - timedelta(days=3)) + + +def test_inherited_reason_is_recognised_as_seeded(): + reason = pf._inherited_entry_reason_v2("기술: RSI 과매도") + assert reason.endswith("기술: RSI 과매도") + row = SimpleNamespace(strategy_name="swing", entry_reason=reason) + assert _xs._is_seeded_active_trade_v2(row) + assert pf._inherited_entry_reason_v2(reason) == reason # 이중 접두사 없음 + + +def test_real_full_exit_not_inherited(monkeypatch): + async def row(*_a, **_k): + return _closed(qty=5) + monkeypatch.setattr(pf, "_recent_closed_non_dt_row_v2", row) + got = asyncio.run(pf._inheritable_closed_row_v3(_DB([(5, 5)]), "005930", 10000.0, NOW)) + assert got is None + + +def test_phantom_close_is_inherited(monkeypatch): + async def row(*_a, **_k): + return _closed(qty=5) + monkeypatch.setattr(pf, "_recent_closed_non_dt_row_v2", row) + got = asyncio.run(pf._inheritable_closed_row_v3(_DB([]), "005930", 10000.0, NOW)) + assert got is not None and got.id == 7 + + +def test_sell_lookup_failure_not_inherited(monkeypatch): + async def row(*_a, **_k): + return _closed(qty=5) + monkeypatch.setattr(pf, "_recent_closed_non_dt_row_v2", row) + assert asyncio.run(pf._inheritable_closed_row_v3(_DB(boom=True), "005930", 10000.0, NOW)) is None + + +def test_entry_price_divergence_not_inherited(monkeypatch): + async def row(*_a, **_k): + return _closed(qty=5, ep=10000.0) + monkeypatch.setattr(pf, "_recent_closed_non_dt_row_v2", row) + avg = 10000.0 * (1 + pf._PHANTOM_INHERIT_MAX_ENTRY_DIVERGENCE + 0.05) + assert asyncio.run(pf._inheritable_closed_row_v3(_DB([]), "005930", avg, NOW)) is None + + +def test_no_untracked_create_task_in_portfolio(): + """fire-and-forget 는 spawn_tracked_v2 로만 (GC 소멸·종료 drain 누락 방지).""" + import inspect + src = inspect.getsource(pf) + code = [l for l in src.splitlines() + if "asyncio.create_task(" in l and not l.lstrip().startswith("#") and "(실시간" not in l] + assert code == [] + + +def test_spawn_tracked_keeps_strong_ref_and_clears(): + from app.core import background_tasks as bt + + async def _go(): + ev = asyncio.Event() + + async def _w(): + await ev.wait() + + t = bt.spawn_tracked_v2(_w()) + assert t in bt._tracked + ev.set() + await t + await asyncio.sleep(0) + assert t not in bt._tracked + + asyncio.run(_go()) diff --git a/backend/tests/test_portfolio_v2_pending_r7.py b/backend/tests/test_portfolio_v2_pending_r7.py new file mode 100644 index 0000000..bb07aee --- /dev/null +++ b/backend/tests/test_portfolio_v2_pending_r7.py @@ -0,0 +1,122 @@ +"""portfolio_v2 /buy·/sell: 접수≠체결, 결과 불명 처리 (manual_buy R3 정합).""" +import pytest +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +from app.api.endpoints import portfolio_v2 as v2 +from app.api.endpoints.manual_buy import BuyRequest +from app.api.endpoints.portfolio import SellRequest +from fastapi import HTTPException +from app.core.config import settings + +CODE = "005930" + + +class _Res: + def __init__(self, v): + self._v = v + + def scalar_one_or_none(self): + return self._v + + def scalar(self): + return 0 + + def scalars(self): + return SimpleNamespace(all=lambda: []) + + +def _ctx(): + return SimpleNamespace( + user_id=1, is_legacy=False, + kis=SimpleNamespace(get_current_price=AsyncMock(return_value={"output": { + "stck_prpr": str(settings.MIN_PRICE_KRW + 50000), "hts_kor_isnm": "T"}})), + loss_tracker=SimpleNamespace(is_banned_safe=AsyncMock(return_value=False), + record_trade_result_safe=AsyncMock(), save_to_db_v2=AsyncMock()), + mdd=SimpleNamespace(can_open_new_position=AsyncMock(return_value=True)), + ) + + +@pytest.fixture +def cap(monkeypatch): + th = [] + + async def _eth(p): + th.append(p) + + pf = AsyncMock() + monkeypatch.setattr(v2, "enqueue_trade_history", _eth) + monkeypatch.setattr(v2, "enqueue_portfolio_upsert", pf) + monkeypatch.setattr(v2, "_last_done_v2", {}) + # 예외 발생형: 미체결/불명 경로에서 상태 변경 함수가 호출되면 즉시 실패 + boom = AsyncMock(side_effect=AssertionError("unexpected state mutation")) + monkeypatch.setattr(v2, "sync_active_trade_after_manual_sell_v2", boom) + monkeypatch.setattr(v2, "reflect_dt_after_manual_sell_v2", boom) + return SimpleNamespace(th=th, pf=pf) + + +def _guard(monkeypatch, qty=3): + g = SimpleNamespace(quantity=qty, to_response=lambda: {}) + monkeypatch.setattr(v2, "evaluate_manual_buy_v2", AsyncMock(return_value=g)) + import app.services.system_buy_registry as r + monkeypatch.setattr(r, "note_system_buy", lambda *a, **k: None) + + +def _db(holding=None): + db = MagicMock() + db.execute = AsyncMock(return_value=_Res(holding)) + return db + + +def _holding(): + return SimpleNamespace(quantity=10, stock_name="T", average_price=100.0, + strategy_type="swing", current_price=100.0) + + +@pytest.mark.asyncio +async def test_limit_buy_is_pending(monkeypatch, cap): + _guard(monkeypatch) + monkeypatch.setattr(v2, "safe_place_order_v2", AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "1"}})) + await v2._buy_stock_impl_v2(BuyRequest(stock_code=CODE, quantity=3, price=70000), CODE, _db(), _ctx()) + p = cap.th[0] + assert p["status"] == "PENDING" and p["executed_quantity"] == 0 and p["executed_at"] is None + cap.pf.assert_not_called() + + +@pytest.mark.asyncio +async def test_market_buy_completed(monkeypatch, cap): + _guard(monkeypatch) + monkeypatch.setattr(v2, "safe_place_order_v2", AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "1"}})) + await v2._buy_stock_impl_v2(BuyRequest(stock_code=CODE, quantity=3), CODE, _db(), _ctx()) + assert cap.th[0]["status"] == "COMPLETED" and cap.th[0]["executed_quantity"] == 3 + + +@pytest.mark.asyncio +async def test_unknown_buy_pending_and_cooldown(monkeypatch, cap): + _guard(monkeypatch) + monkeypatch.setattr(v2, "safe_place_order_v2", + AsyncMock(return_value={"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN"})) + with pytest.raises(HTTPException) as e: + await v2._buy_stock_impl_v2(BuyRequest(stock_code=CODE, quantity=3), CODE, _db(), _ctx()) + assert e.value.status_code == 504 + assert cap.th[0]["status"] == "PENDING" and cap.th[0]["executed_quantity"] == 0 + assert (1, CODE) in v2._last_done_v2 + + +@pytest.mark.asyncio +async def test_unknown_sell_no_state_change(monkeypatch, cap): + monkeypatch.setattr(v2, "safe_place_order_v2", AsyncMock(return_value={})) + with pytest.raises(HTTPException) as e: + await v2.sell_stock_v2(SellRequest(stock_code=CODE, quantity=5), _ctx(), _db(_holding())) + assert e.value.status_code == 504 + assert cap.th[0]["status"] == "PENDING" + cap.pf.assert_not_called() + + +@pytest.mark.asyncio +async def test_limit_sell_pending_no_state_change(monkeypatch, cap): + monkeypatch.setattr(v2, "safe_place_order_v2", AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "2"}})) + r = await v2.sell_stock_v2(SellRequest(stock_code=CODE, quantity=5, price=200.0), _ctx(), _db(_holding())) + assert cap.th[0]["status"] == "PENDING" and cap.th[0]["executed_quantity"] == 0 + assert r["pending"] is True + cap.pf.assert_not_called() diff --git a/backend/tests/test_realtime_chart_fixes_20261009.py b/backend/tests/test_realtime_chart_fixes_20261009.py new file mode 100644 index 0000000..e820b12 --- /dev/null +++ b/backend/tests/test_realtime_chart_fixes_20261009.py @@ -0,0 +1,595 @@ +"""2026-10-09 실시간 스트림·차트 감사 결함 회귀 (H-3/H-4/H-5/H-6, M-1/M-5/M-7, L-3/L-4/L-5/L-6/L-9). + +외부 호출 없음: KIS WS 는 가짜 connect, KIS REST·네이버·Redis·DB 는 전부 목. +""" +import asyncio +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +import pytz + +from app.api.endpoints import market_data as md +from app.services import kis_realtime_stream as krs +from app.services.websocket_manager import WebSocketManager + +KST = pytz.timezone("Asia/Seoul") + + +def _run(coro): + return asyncio.run(coro) + + +class _FakeKisWS: + """KIS WS 대역: 주어진 프레임을 내보낸 뒤 정상 종료(ConnectionClosedOK 와 동일하게 iteration 종료).""" + + def __init__(self, frames=()): + self.frames = list(frames) + self.sent = [] + + async def send(self, msg): + self.sent.append(msg) + + def __aiter__(self): + return self._gen() + + async def _gen(self): + for f in self.frames: + await asyncio.sleep(0) + yield f + + +class _FakeManager: + def __init__(self, subscribed=(), selected=()): + self.subscribed = set(subscribed) + self.selected = set(selected) + self.updates = [] + + def get_all_subscribed_codes(self): + return set(self.subscribed) + + def get_selected_codes_v2(self): + return set(self.selected) + + async def send_market_update(self, code, data): + self.updates.append((code, data)) + + +def _stream(manager): + s = krs.KisRealtimeStream() + s._approval_key = "k" + return s, patch.object(krs, "websocket_manager", manager) + + +def _parse_sent(sent): + import json + out = [] + for m in sent: + j = json.loads(m) + out.append((j["header"]["tr_type"], j["body"]["input"]["tr_id"], j["body"]["input"]["tr_key"])) + return out + + +async def _no_sleep(*_a, **_k): + return None + + +# ── M-1 다건 체결 프레임 ───────────────────────────────────────────────────── +def _tick_record(code, price, vol): + f = ["0"] * 46 + f[0], f[1], f[2], f[5] = code, "101500", str(price), "1.5" + f[7], f[8], f[9] = "100", "110", "95" + f[13], f[14] = str(vol), "999" + return f + + +def test_multi_record_tick_uses_last_record(): + body = "^".join(_tick_record("005930", 101, 10) + _tick_record("005930", 105, 20)) + parsed = krs._parse_realtime_message(f"0|H0STCNT0|002|{body}") + assert parsed["price"] == 105.0 and parsed["volume"] == 20 + assert parsed["open"] == 100.0 and parsed["high"] == 110.0 and parsed["low"] == 95.0 + + +def test_single_record_tick_unchanged(): + body = "^".join(_tick_record("000660", 200, 7)) + parsed = krs._parse_realtime_message(f"0|H0STCNT0|001|{body}") + assert parsed["stock_code"] == "000660" and parsed["price"] == 200.0 and parsed["volume"] == 7 + + +# ── H-4 구독 한도·우선순위 ─────────────────────────────────────────────────── +def test_target_registrations_priority_quote_only_selected_and_no_us(): + targets, dropped = krs._build_target_registrations( + {"005930", "000660", "AAPL", "BRK.B", "0004V0"}, {"000660"}, 41) + assert targets[0] == (krs.TR_TICK, "000660") and targets[1] == (krs.TR_QUOTE, "000660") + quotes = [c for tr, c in targets if tr == krs.TR_QUOTE] + assert quotes == ["000660"] + codes = {c for _, c in targets} + assert "AAPL" not in codes and "BRK.B" not in codes and "0004V0" in codes + assert dropped == [] + + +def test_target_registrations_budget_cut_keeps_selected(): + holdings = {f"{i:06d}" for i in range(1, 60)} + targets, dropped = krs._build_target_registrations(holdings | {"999999"}, {"999999"}, 41) + assert len(targets) == 41 and len(dropped) == 20 # 선택 체결+호가 2 + 보유 59 = 61 → 41 유지 + assert targets[:2] == [(krs.TR_TICK, "999999"), (krs.TR_QUOTE, "999999")] + + +def test_sync_sends_unsubscribe_before_subscribe(): + mgr = _FakeManager(subscribed={"000660"}, selected=set()) + s, p = _stream(mgr) + s._registered = {(krs.TR_TICK, "005930"), (krs.TR_QUOTE, "005930")} + ws = _FakeKisWS() + with p, patch.object(krs.asyncio, "sleep", _no_sleep): + _run(s._sync_subscriptions(ws)) + sent = _parse_sent(ws.sent) + assert [t for t, _, _ in sent] == ["2", "2", "1"] + assert sent[-1] == ("1", krs.TR_TICK, "000660") + assert s._registered == {(krs.TR_TICK, "000660")} and s._subscribed_codes == {"000660"} + + +def test_ack_failure_removes_registration_and_backs_off(): + mgr = _FakeManager(subscribed={"005930"}) + s, p = _stream(mgr) + ws = _FakeKisWS() + with p, patch.object(krs.asyncio, "sleep", _no_sleep): + _run(s._sync_subscriptions(ws)) + assert (krs.TR_TICK, "005930") in s._registered + s._handle_ack({"header": {"tr_id": krs.TR_TICK, "tr_key": "005930"}, + "body": {"rt_cd": "1", "msg_cd": "OPSP0008", "msg1": "MAX SUBSCRIBE OVER"}}) + assert (krs.TR_TICK, "005930") not in s._registered + assert s._subscribed_codes == set() + ws.sent.clear() + _run(s._sync_subscriptions(ws)) # 백오프 중 — 재전송 안 함 + assert ws.sent == [] + s._ack_fail_until[(krs.TR_TICK, "005930")] = 0.0 # 백오프 만료 + _run(s._sync_subscriptions(ws)) + assert _parse_sent(ws.sent) == [("1", krs.TR_TICK, "005930")] + + +def test_ack_already_subscribed_is_success(): + s, _ = _stream(_FakeManager()) + s._registered = {(krs.TR_TICK, "005930")} + s._handle_ack({"header": {"tr_id": krs.TR_TICK, "tr_key": "005930"}, + "body": {"rt_cd": "1", "msg1": "ALREADY IN SUBSCRIBE"}}) + assert (krs.TR_TICK, "005930") in s._registered + + +# ── H-3 정상 close 후 재구독 / H-5 별도 동기화 태스크 ────────────────────────── +def test_normal_close_clears_state_and_resubscribes_on_reconnect(): + mgr = _FakeManager(subscribed={"005930"}) + s, p = _stream(mgr) + sessions = [] + + class _Ctx: + def __init__(self, ws): + self.ws = ws + + async def __aenter__(self): + return self.ws + + async def __aexit__(self, *a): + return False + + def _connect(url, **kwargs): + assert krs._ws_header_kwarg_name() in kwargs + ws = _FakeKisWS(frames=['{"header":{"tr_id":"PINGPONG"}}']) + sessions.append(ws) + if len(sessions) >= 2: + s._running = False + return _Ctx(ws) + + async def _key_ok(): + s._approval_key = "k" + return True + + fake_ws_mod = SimpleNamespace(connect=_connect, __version__="15.0.1") + with p, patch.object(krs, "websockets", fake_ws_mod), \ + patch.object(krs.asyncio, "sleep", _no_sleep), \ + patch.object(s, "_ensure_approval_key", _key_ok): + _run(s.run()) + assert len(sessions) == 2 + for ws in sessions: # 두 번째 세션도 처음부터 구독 메시지를 보냈다 + assert ("1", krs.TR_TICK, "005930") in _parse_sent([m for m in ws.sent if "approval_key" in m]) + assert s._registered == set() and s._subscribed_codes == set() + + +def test_sync_loop_wakes_on_notify_without_incoming_frames(): + mgr = _FakeManager(subscribed=set()) + s, p = _stream(mgr) + ws = _FakeKisWS() + + async def scenario(): + task = asyncio.create_task(s._sync_loop(ws)) + await asyncio.sleep(0) + mgr.subscribed = {"005930"} + s.notify_subscription_change() + for _ in range(50): + await asyncio.sleep(0) + if ws.sent: + break + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + with p, patch.object(krs, "_SYNC_INTERVAL", 3600): + _run(scenario()) + assert _parse_sent(ws.sent) == [("1", krs.TR_TICK, "005930")] + + +# ── L-6 수신 루프는 큐잉만, 분배는 별도 ─────────────────────────────────────── +def test_receive_path_only_enqueues_and_dispatch_coalesces(): + mgr = _FakeManager(subscribed={"005930"}) + s, p = _stream(mgr) + ws = _FakeKisWS() + cache = AsyncMock() + t1 = "0|H0STCNT0|001|" + "^".join(_tick_record("005930", 101, 1)) + t2 = "0|H0STCNT0|001|" + "^".join(_tick_record("005930", 102, 2)) + + async def scenario(): + await s._on_raw_message(ws, t1) + await s._on_raw_message(ws, t2) + assert cache.await_count == 0 and mgr.updates == [] # 수신 경로에서 Redis/전송 없음 + n = await s._dispatch_once() + return n + + with p, patch.object(krs, "_cache_realtime_data", cache): + n = _run(scenario()) + assert n == 1 and len(mgr.updates) == 1 and mgr.updates[0][1]["price"] == 102.0 + + +def test_dispatch_redis_timeout_does_not_block_send(): + mgr = _FakeManager(subscribed={"005930"}) + s, p = _stream(mgr) + + async def slow_cache(*a, **k): + await asyncio.sleep(10) + + with p, patch.object(krs, "_cache_realtime_data", slow_cache), \ + patch.object(krs, "_REDIS_WRITE_TIMEOUT", 0.01): + _run(s._dispatch_one("tick", "005930", {"price": 1})) + assert mgr.updates == [("005930", {"price": 1})] + + +# ── L-9 websockets 버전별 헤더 인자 ─────────────────────────────────────────── +def test_ws_header_kwarg_by_version(): + with patch.object(krs, "websockets", SimpleNamespace(__version__="12.0")): + assert krs._ws_header_kwarg_name() == "extra_headers" + with patch.object(krs, "websockets", SimpleNamespace(__version__="15.0.1")): + assert krs._ws_header_kwarg_name() == "additional_headers" + + +# ── L-5 / L-6 websocket_manager 전송 ────────────────────────────────────────── +class _FakeClientWS: + def __init__(self, delay=0.0, fail=False): + self.delay, self.fail, self.sent = delay, fail, [] + + async def send_text(self, m): + if self.delay: + await asyncio.sleep(self.delay) + if self.fail: + raise RuntimeError("closed") + self.sent.append(m) + + +def test_market_update_without_subscribers_is_not_broadcast(): + m = WebSocketManager() + a = _FakeClientWS() + m.active_connections["a"] = a + m._subscriptions["a"] = {"000660"} + _run(m.send_market_update("005930", {"price": 1})) + assert a.sent == [] + + +def test_market_update_slow_client_times_out_and_others_receive(): + from app.services import websocket_manager as wm + m = WebSocketManager() + slow, fast = _FakeClientWS(delay=10), _FakeClientWS() + m.active_connections.update({"slow": slow, "fast": fast}) + m._subscriptions.update({"slow": {"005930"}, "fast": {"005930"}}) + with patch.object(wm, "_MARKET_UPDATE_SEND_TIMEOUT", 0.01): + _run(m.send_market_update("005930", {"price": 1})) + assert len(fast.sent) == 1 and "slow" not in m.active_connections and "fast" in m.active_connections + + +def test_selected_codes_only_for_active_subscribed(): + m = WebSocketManager() + m.active_connections["a"] = _FakeClientWS() + m._subscriptions["a"] = {"005930"} + m.set_selected_codes_v2("a", ["005930"]) + m.set_selected_codes_v2("gone", ["000660"]) + assert m.get_selected_codes_v2() == {"005930"} + m.disconnect("a") + assert m.get_selected_codes_v2() == set() + + +# ── H-6 샘플 난수 차트 금지 / 해외 차트 ────────────────────────────────────── +class _NaverCallBlocked(BaseException): + """네이버 실호출 차단. BaseException 이라 market_data 의 `except Exception` 폴백에 삼켜지지 않는다.""" + + +@pytest.fixture(autouse=True) +def _clear_chart_cache(): + md.chart_cache.clear() + # 파일 단위 안전망: 개별 테스트가 패치를 빠뜨려도 실제 네이버(fchart) 접속 불가. + # 개별 테스트의 patch(...) 는 이 목을 덮어쓴다. + with patch("app.services.naver_finance.get_naver_daily_chart", + AsyncMock(side_effect=_NaverCallBlocked("네이버 실호출 금지"))), \ + patch("app.core.redis.setex_json", AsyncMock(return_value=True)): + yield + md.chart_cache.clear() + + +def test_no_data_returns_empty_with_reason_not_sample(): + with patch.object(md, "_fetch_chart_from_db", AsyncMock(return_value=[])), \ + patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=None)): + res = _run(md.get_chart_data_cached_v2("005930", "D", 200)) + legacy = _run(md.get_chart_data_cached("005930", "D", 200)) + assert res == {"data": [], "is_sample": False, "reason": "no_data", "source": None} + assert legacy == [] + + +def test_overseas_ticker_uses_overseas_chart(): + kis = SimpleNamespace(get_overseas_daily_chart=AsyncMock(return_value={"output2": [ + {"xymd": "20261008", "open": "10", "high": "12", "low": "9", "clos": "11", "tvol": "100"}, + {"xymd": "20261007", "open": "9", "high": "10", "low": "8", "clos": "10", "tvol": "50"}, + {"xymd": "20261006", "open": "0", "high": "0", "low": "0", "clos": "0", "tvol": "0"}, + ]}), get_current_price=AsyncMock(side_effect=AssertionError("국내 경로 호출 금지"))) + db = AsyncMock(side_effect=AssertionError("DB 호출 금지")) + with patch.object(md, "korea_investment_api", kis), patch.object(md, "_fetch_chart_from_db", db): + res = _run(md.get_chart_data_cached_v2("AAPL", "D", 200)) + assert [b["date"] for b in res["data"]] == ["20261007", "20261008"] + assert res["reason"] is None and res["is_sample"] is False + + +def test_overseas_chart_failure_reason(): + kis = SimpleNamespace(get_overseas_daily_chart=AsyncMock(side_effect=RuntimeError("down"))) + with patch.object(md, "korea_investment_api", kis): + res = _run(md.get_chart_data_cached_v2("TSLA", "D", 100)) + assert res["data"] == [] and res["reason"] == "overseas_chart_unavailable" + + +def test_overseas_ticker_detection(): + assert md._is_overseas_ticker("AAPL") and md._is_overseas_ticker("BRK.B") + assert not md._is_overseas_ticker("005930") and not md._is_overseas_ticker("0004V0") + assert not md._is_overseas_ticker("") + + +def test_ws_chart_response_fields_and_bad_limit(): + with patch.object(md, "get_chart_data_cached_v2", + AsyncMock(return_value={"data": [], "is_sample": False, + "reason": "no_data", "source": None})) as g: + resp = _run(md.build_ws_chart_response_v2("005930", "D", "abc")) + assert g.await_args.args[2] == 200 # L-3 변환 불가 → 기본값 + assert resp == {"type": "chart_data", "stock_code": "005930", "period": "D", "data": [], + "is_sample": False, "reason": "no_data", "error": None} + with patch.object(md, "get_chart_data_cached_v2", AsyncMock(side_effect=RuntimeError("x"))): + resp = _run(md.build_ws_chart_response_v2("005930", "W", 50)) + assert resp["data"] == [] and resp["reason"] == "chart_fetch_failed" and resp["error"] == "RuntimeError" + + +def test_parse_chart_limit_bounds(): + assert md.parse_chart_limit_v2(None) == 200 + assert md.parse_chart_limit_v2("9999") == 240 + assert md.parse_chart_limit_v2(-5) == 1 + assert md.parse_chart_limit_v2("50") == 50 + + +# ── M-5 DB 0 OHLC 필터 ─────────────────────────────────────────────────────── +def test_db_chart_filters_zero_ohlc_rows(): + rows = [ # newest first + SimpleNamespace(trade_date=datetime(2026, 10, 8), open_price=10, high_price=12, low_price=9, + close_price=11, volume=5), + SimpleNamespace(trade_date=datetime(2026, 10, 7), open_price=0, high_price=0, low_price=0, + close_price=0, volume=0), + SimpleNamespace(trade_date=datetime(2026, 10, 6), open_price=9, high_price=10, low_price=None, + close_price=9, volume=1), + SimpleNamespace(trade_date=datetime(2026, 10, 5), open_price=8, high_price=9, low_price=7, + close_price=8, volume=2), + ] + + class _Res: + def scalars(self): + return SimpleNamespace(all=lambda: rows) + + class _Sess: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *_a, **_k): + return _Res() + + def add(self, *_a): + raise AssertionError("쓰기 금지") + + async def commit(self): + raise AssertionError("쓰기 금지") + + with patch("app.core.database.AsyncSessionLocal", lambda: _Sess()): + out = _run(md._fetch_chart_from_db("005930", 10)) + assert [b["date"] for b in out] == ["20261005", "20261008"] + + +# ── M-7 Redis tick 우선 / 캐시 ttl ─────────────────────────────────────────── +def test_today_bar_prefers_redis_tick_over_kis(): + now = KST.localize(datetime(2026, 10, 8, 11, 0)) + db = [{"date": "20261007", "open": 1, "high": 1, "low": 1, "close": 1, "volume": 1}] + kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=AssertionError("KIS 호출 금지"))) + tick = {"price": 44100.0, "open": 44500.0, "high": 44800.0, "low": 43900.0, "volume": 7777} + with patch.object(md, "korea_investment_api", kis), \ + patch("app.services.naver_finance.get_naver_daily_chart", + AsyncMock(side_effect=AssertionError("네이버 실호출 금지"))), \ + patch("app.core.redis.get_json", AsyncMock(return_value=tick)): + bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) + assert live and bars[-1] == {"date": "20261008", "open": 44500.0, "high": 44800.0, + "low": 43900.0, "close": 44100.0, "volume": 7777} + + +def test_chart_never_calls_kis_when_tick_missing_or_incomplete(): + """R1: tick 이 없거나 OHLC 불완전해도 차트 경로는 KIS REST 를 호출하지 않는다(당일봉 없음).""" + now = KST.localize(datetime(2026, 10, 8, 11, 0)) + kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=AssertionError("KIS 호출 금지"))) + for tick in (None, {"price": 100.0}): + db = [{"date": "20261007", "open": 1, "high": 1, "low": 1, "close": 1, "volume": 1}] + with patch.object(md, "korea_investment_api", kis), \ + patch("app.services.naver_finance.get_naver_daily_chart", + AsyncMock(return_value=None)), \ + patch("app.core.redis.get_json", AsyncMock(return_value=tick)): + bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) + assert live is False and bars[-1]["date"] == "20261007" + assert kis.get_current_price.await_count == 0 + + +def test_chart_cache_fresh_respects_entry_ttl(): + md.chart_cache["005930__D__200"] = {"timestamp": datetime.now() - timedelta(seconds=60), + "data": [], "ttl": 30} + assert md.is_chart_cache_fresh_v2("005930", "D", 200) is False + md.chart_cache["005930__D__200"]["ttl"] = 600 + assert md.is_chart_cache_fresh_v2("005930", "D", 200) is True + + +# ── L-4 네이버 폴백 주/월봉 조회 기간 ───────────────────────────────────────── +def test_naver_fallback_weekly_requests_enough_days_and_keeps_all_rows(): + daily = [] + d = datetime(2025, 1, 6) + while len(daily) < 300: + if d.weekday() < 5: + daily.append({"stck_bsop_date": d.strftime("%Y%m%d"), "stck_oprc": "10", "stck_hgpr": "11", + "stck_lwpr": "9", "stck_clpr": "10", "acml_vol": "1"}) + d += timedelta(days=1) + naver = AsyncMock(return_value=daily) + with patch.object(md, "_fetch_chart_from_db", AsyncMock(return_value=[])), \ + patch("app.services.naver_finance.get_naver_daily_chart", naver), \ + patch.object(md, "_merge_recent_daily_bars", AsyncMock(side_effect=lambda c, b: (b, False))): + res = _run(md.get_chart_data_cached_v2("005930", "W", 50)) + assert naver.await_args_list[0].kwargs["days"] == 50 * 7 + assert len(res["data"]) == 50 # 300 거래일 = 60주 → 최신 50주 (과거: 50일 → 10주) + + +def test_in_session_without_tick_uses_short_cache_ttl(): + """장중 tick 미도착(live=False)이어도 당일봉 없는 결과를 10분 캐시하지 않는다.""" + bars = [{"date": "20250103", "open": 1, "high": 2, "low": 1, "close": 1, "volume": 1}] + naver = AsyncMock(return_value=None) + with patch("app.services.naver_finance.get_naver_daily_chart", naver), \ + patch.object(md, "_fetch_chart_from_db", AsyncMock(return_value=list(bars))), \ + patch.object(md, "_merge_recent_daily_bars", AsyncMock(side_effect=lambda c, b: (b, False))), \ + patch.object(md, "_chart_session_state", lambda now: ("20250103", True)): + _run(md.get_chart_data_cached_v2("005930", "D", 200)) + assert md.chart_cache["005930__D__200"]["ttl"] == md._CHART_LIVE_TTL + md.chart_cache.clear() + with patch("app.services.naver_finance.get_naver_daily_chart", naver), \ + patch.object(md, "_fetch_chart_from_db", AsyncMock(return_value=list(bars))), \ + patch.object(md, "_merge_recent_daily_bars", AsyncMock(side_effect=lambda c, b: (b, False))), \ + patch.object(md, "_chart_session_state", lambda now: ("20250103", False)): + _run(md.get_chart_data_cached_v2("005930", "D", 200)) + assert md.chart_cache["005930__D__200"]["ttl"] == md._CHART_CACHE_TTL + + +# ── 라운드3: 다종목 프레임 / 즉시-close 백오프 ───────────────────────────────── +def test_mixed_code_frame_enqueues_each_code_latest(): + mgr = _FakeManager(subscribed={"005930", "000660"}) + s, p = _stream(mgr) + body = "^".join(_tick_record("005930", 101, 1) + _tick_record("000660", 200, 2) + + _tick_record("005930", 103, 3)) + with p: + _run(s._on_raw_message(_FakeKisWS(), f"0|H0STCNT0|003|{body}")) + got = {k[1]: v["price"] for k, v in s._latest.items()} + assert got == {"005930": 103.0, "000660": 200.0} + + +def test_instant_close_backoff_grows_and_reissues_once_per_streak(): + mgr = _FakeManager(subscribed=set()) + s, p = _stream(mgr) + sessions, sleeps, invalidations, alerts = [], [], [], [] + + class _Ctx: + async def __aenter__(self): + return _FakeKisWS(frames=[]) + + async def __aexit__(self, *a): + return False + + def _connect(url, **kwargs): + sessions.append(1) + if len(sessions) >= 7: + s._running = False + return _Ctx() + + async def _key_ok(): + s._approval_key = "k" + return True + + async def _sleep(d, *a, **k): + sleeps.append(d) + + async def _alert(total): + alerts.append(total) + + fake_ws_mod = SimpleNamespace(connect=_connect, __version__="15.0.1") + with p, patch.object(krs, "websockets", fake_ws_mod), \ + patch.object(krs.asyncio, "sleep", _sleep), \ + patch.object(krs, "_invalidate_cached_approval_key", lambda: invalidations.append(1)), \ + patch.object(krs, "_alert_instant_close_v2", _alert), \ + patch.object(s, "_ensure_approval_key", _key_ok): + _run(s.run()) + base = krs._RECONNECT_WAIT + assert sleeps[:4] == [min(base * 2 ** i, krs._RECONNECT_MAX_WAIT) for i in range(4)] + # 7회 close, streak=3 → 재발급 2회(3·6번째)이지 6회가 아니다 + assert len(invalidations) == 7 // krs._NORMAL_CLOSE_REISSUE_STREAK + assert alerts == [krs._NORMAL_CLOSE_REISSUE_STREAK] + + +# ── R8 daybar 누적(저유동 종목 당일봉 유지) ────────────────────────────────── +def _store_patches(store): + async def _get(key): + return store.get(key) + + async def _set(key, ttl, data): + store[key] = data + store["__ttl__" + key] = ttl + return True + return patch("app.core.redis.get_json", _get), patch("app.core.redis.setex_json", _set) + + +def test_daybar_persists_when_tick_expired_and_accumulates_extremes(): + store = {} + g, s = _store_patches(store) + with g, s: + t1 = {"price": 100.0, "open": 98.0, "high": 105.0, "low": 97.0, "volume": 10} + store["tick:009420"] = t1 + b1 = _run(md._today_bar_from_tick("009420", "20261008")) + assert b1["open"] == 98.0 and b1["high"] == 105.0 + # tick TTL 만료 → daybar 로 당일봉 유지 + del store["tick:009420"] + b2 = _run(md._today_bar_from_tick("009420", "20261008")) + assert b2 == b1 + # 새 tick 이 더 낮은 low 이면 누적 극값 유지 + 신규 갱신 + store["tick:009420"] = {"price": 101.0, "open": 98.0, "high": 104.0, "low": 99.0, "volume": 12} + b3 = _run(md._today_bar_from_tick("009420", "20261008")) + assert b3["high"] == 105.0 and b3["low"] == 97.0 and b3["close"] == 101.0 + assert store["__ttl__daybar:009420"] >= md._DAYBAR_MIN_TTL + + +def test_daybar_ignores_previous_day_value(): + store = {"daybar:009420": {"date": "20261007", "price": 1.0, "open": 1.0, "high": 1.0, "low": 1.0, "volume": 1}} + g, s = _store_patches(store) + with g, s: + assert _run(md._today_bar_from_tick("009420", "20261008")) is None + + +def test_post_close_window_uses_short_cache_ttl(): + bars = [{"date": "20261008", "open": 1, "high": 2, "low": 1, "close": 1, "volume": 1}] + now = KST.localize(datetime(2026, 10, 8, 15, 35)) + assert md._is_post_close_window(now) + assert not md._is_post_close_window(KST.localize(datetime(2026, 10, 8, 15, 45))) + with patch.object(md, "_fetch_chart_from_db", AsyncMock(return_value=list(bars))), \ + patch.object(md, "_merge_recent_daily_bars", AsyncMock(side_effect=lambda c, b: (b, False))), \ + patch.object(md, "_chart_session_state", lambda n: ("20261008", False)), \ + patch.object(md, "datetime", SimpleNamespace(now=lambda tz=None: now if tz else now.replace(tzinfo=None), + strptime=datetime.strptime)): + _run(md.get_chart_data_cached_v2("005930", "D", 200)) + assert md.chart_cache["005930__D__200"]["ttl"] == md._CHART_LIVE_TTL diff --git a/backend/tests/test_recommendations_price_date_20261009.py b/backend/tests/test_recommendations_price_date_20261009.py new file mode 100644 index 0000000..797b2b4 --- /dev/null +++ b/backend/tests/test_recommendations_price_date_20261009.py @@ -0,0 +1,47 @@ +from datetime import date, datetime +from types import SimpleNamespace as NS + +from app.api.endpoints import recommendations as r + + +def test_premarket_row_price_date_unknown_and_stale(): + st = NS(analysis_date=date(2026, 9, 30), updated_at=datetime(2026, 9, 30, 8, 54)) + assert r._row_price_date_v2(st) == ("", True) + + +def test_intraday_row_price_date_is_analysis_date(): + st = NS(analysis_date=date(2026, 9, 30), updated_at=datetime(2026, 9, 30, 10, 0)) + assert r._row_price_date_v2(st) == ("20260930", False) + + +def test_persistable_filters_stale_price_date(): + today = date(2026, 10, 8) + mk = lambda c, pd: (c, c, NS(price_date=pd)) + out = r._persistable_items_v2([mk("a", "20261007"), mk("b", "20261008"), mk("c", "")], today) + assert [x[0] for x in out] == ["b", "c"] + + +def test_bg_trade_task_strongly_referenced_until_done(): + import asyncio + + async def run(): + ev = asyncio.Event() + + async def work(): + await ev.wait() + + t = r._spawn_bg_trade_v2(work()) + assert t in r._bg_trade_tasks + ev.set() + await t + await asyncio.sleep(0) + assert t not in r._bg_trade_tasks + + asyncio.run(run()) + + +def test_rule_based_has_no_bare_create_task(): + import inspect + src = inspect.getsource(r.get_rule_based_recommendations) + assert "asyncio.create_task(" not in src + assert "_spawn_bg_trade_v2(" in src diff --git a/backend/tests/test_regime_kis_fallback_r3.py b/backend/tests/test_regime_kis_fallback_r3.py new file mode 100644 index 0000000..366f593 --- /dev/null +++ b/backend/tests/test_regime_kis_fallback_r3.py @@ -0,0 +1,101 @@ +from datetime import datetime +import pytest +from app.services import market_analyzer as ma + + +def _rows(n): + return [{"stck_clpr": str(100 + i * 0.1), "stck_hgpr": str(101 + i * 0.1), + "stck_lwpr": str(99 + i * 0.1), "acml_vol": "1000"} for i in range(n)][::-1] + + +class _Api: + def __init__(self, rng, chart): + self.rng, self.chart, self.calls = rng, chart, [] + + async def get_daily_ohlcv_range(self, *a): + self.calls.append("range") + if isinstance(self.rng, Exception): + raise self.rng + return self.rng + + async def get_daily_chart(self, *a, **k): + self.calls.append("chart") + return {"output2": self.chart} + + +@pytest.fixture(autouse=True) +def _setup(monkeypatch): + saved = dict(ma._market_regime_cache) + ma._market_regime_cache.update(result=None, ts=0.0) + monkeypatch.setattr(ma, "is_market_open", lambda: True) + monkeypatch.setattr(ma.settings, "TRADING_HALTED", True, raising=False) + + async def _naver_fail(*a, **k): + raise RuntimeError("naver down") + import app.services.naver_finance as nf + monkeypatch.setattr(nf, "get_naver_daily_chart", _naver_fail) + yield + ma._market_regime_cache.update(saved) + + +@pytest.mark.asyncio +async def test_kis_range_fallback_reaches_205(monkeypatch): + api = _Api(_rows(250), _rows(30)) + monkeypatch.setattr(ma, "_real_market_api", api) + r = await ma.check_market_regime() + assert r["regime"] == "halted" # 데이터 부족 분기를 통과해 TRADING_HALTED 까지 도달 + assert api.calls == ["range"] + assert ma.get_regime_source_health()["source"] == "kis_range" + + +@pytest.mark.asyncio +async def test_short_data_blocks_with_error_log(monkeypatch, caplog): + api = _Api(RuntimeError("kis down"), _rows(30)) + monkeypatch.setattr(ma, "_real_market_api", api) + caplog.set_level("ERROR") + r = await ma.check_market_regime() + assert r["max_trades"] == 0 + assert any("신규 진입 차단" in m for m in caplog.messages) + h = ma.get_regime_source_health() + assert h["blocked"] is True and h["bars"] == 30 + + +def _dated(rows, date): + out = [dict(r) for r in rows] + for i, r in enumerate(out): + r["stck_bsop_date"] = date if i == 0 else "20200101" + return out + + +@pytest.mark.asyncio +async def test_stale_naver_falls_to_kis_fresh(monkeypatch): + import app.services.naver_finance as nf + monkeypatch.setattr(ma, "_get_kst_now", lambda: datetime(2026, 10, 9, 10, 0)) + old = list(reversed(_dated(_rows(250), "20261008"))) # oldest-first, 최신봉=전일 + + async def _naver(*a, **k): + return old + monkeypatch.setattr(nf, "get_naver_daily_chart", _naver) + api = _Api(_dated(_rows(250), "20261009"), _rows(30)) + monkeypatch.setattr(ma, "_real_market_api", api) + await ma.check_market_regime() + h = ma.get_regime_source_health() + assert h["source"] == "kis_range" and h["stale"] is False + assert api.calls == ["range"] + + +@pytest.mark.asyncio +async def test_all_sources_stale_warns_and_flags(monkeypatch, caplog): + import app.services.naver_finance as nf + monkeypatch.setattr(ma, "_get_kst_now", lambda: datetime(2026, 10, 9, 10, 0)) + old = list(reversed(_dated(_rows(250), "20261008"))) + + async def _naver(*a, **k): + return old + monkeypatch.setattr(nf, "get_naver_daily_chart", _naver) + monkeypatch.setattr(ma, "_real_market_api", _Api(_dated(_rows(250), "20261008"), _rows(30))) + caplog.set_level("WARNING") + r = await ma.check_market_regime() + assert r["regime"] == "halted" + assert ma.get_regime_source_health()["stale"] is True + assert any("낡음" in m for m in caplog.messages) diff --git a/backend/tests/test_restart_durability_20260928.py b/backend/tests/test_restart_durability_20260928.py index c47ad70..a27e773 100644 --- a/backend/tests/test_restart_durability_20260928.py +++ b/backend/tests/test_restart_durability_20260928.py @@ -1032,7 +1032,7 @@ def test_legacy_day_trading_endpoint_state_is_dead_path(): assert "day_trade_scheduler()" not in main_src, ( "레거시 스케줄러가 기동되면 _closed_today 경로가 되살아난다 → F9 저장 배선 필요" ) - assert "daytrading_scheduler()" in main_src + assert "daytrading_scheduler" in main_src assert "DEPRECATED" in inspect.getdoc(sch.day_trade_scheduler) diff --git a/backend/tests/test_risk_management_endpoints_r4.py b/backend/tests/test_risk_management_endpoints_r4.py new file mode 100644 index 0000000..bf65aa6 --- /dev/null +++ b/backend/tests/test_risk_management_endpoints_r4.py @@ -0,0 +1,224 @@ +"""risk_management 하드닝 R4: GET/check 는 DB 쓰기 금지, 호출자 잔고 신뢰 제거.""" +import asyncio +from decimal import Decimal +from unittest.mock import MagicMock + +import pytest + +from app.api.endpoints import risk_management as rm +from app.services.mdd_manager import mdd_manager + + +_REAL_SWING_CALC = rm._swing_realized_today_v2 + + +class _NoWriteDB: + """add/commit/refresh 호출 시 즉시 예외(그럴듯한 값 반환 금지).""" + def __init__(self, session=None): + self._session = session + + async def execute(self, *_a, **_k): + r = MagicMock() + r.scalar_one_or_none.return_value = self._session + return r + + def add(self, *_a, **_k): + raise AssertionError("db.add 금지") + + async def commit(self): + raise AssertionError("db.commit 금지") + + async def refresh(self, *_a, **_k): + raise AssertionError("db.refresh 금지") + + +def _sess(**kw): + s = rm.TradingSession(session_date=rm._today_kst(), consecutive_losses=0, + consecutive_wins=0, daily_profit_loss=Decimal("0"), + daily_trades_count=0, is_paused=False) + for k, v in kw.items(): + setattr(s, k, v) + return s + + +@pytest.fixture(autouse=True) +def _server_balance(monkeypatch): + async def _none(_db, stock_code=None): + return 0.0, 0 + monkeypatch.setattr(rm, "_swing_realized_today_v2", _none) + monkeypatch.setattr(mdd_manager, "_last_balance", 10_000_000.0, raising=False) + + +def test_get_today_session_no_write_when_missing(): + r = asyncio.run(rm.get_today_session(db=_NoWriteDB(None))) + assert r["is_paused"] is False and r["daily_trades_count"] == 0 + + +def test_check_limits_no_write_when_session_missing(): + r = asyncio.run(rm.check_risk_limits(order_value=100_000, account_balance=10_000_000, + strategy="swing", db=_NoWriteDB(None))) + assert r["allowed"] is True + + +def test_inflated_caller_balance_cannot_dilute_daily_loss(): + s = _sess(daily_profit_loss=Decimal("-400000")) # 서버잔고 대비 -4% + r = asyncio.run(rm.check_risk_limits(order_value=100_000, account_balance=1_000_000_000, + strategy="swing", db=_NoWriteDB(s))) + assert r["allowed"] is False and r["risk_level"] == "CRITICAL" + assert s.is_paused is False # 영속/변경 없음 + + +def test_inflated_balance_cannot_dilute_position_cap(): + cap = rm.MAX_POSITION_SIZE_RATIO + order = 10_000_000 * (cap + 0.02) + r = asyncio.run(rm.check_risk_limits(order_value=order, account_balance=1_000_000_000, + strategy="swing", db=_NoWriteDB(_sess()))) + assert r["allowed"] is False and r["risk_level"] == "HIGH" + + +def test_unverified_when_server_balance_unknown(monkeypatch): + monkeypatch.setattr(mdd_manager, "_last_balance", 0.0, raising=False) + bal, note = rm._resolve_account_balance(5_000_000) + assert bal == 5_000_000 and note == "server_balance_unverified" + + +# ---- R1: update-session 제거 + 서버측 가드 ---- +def test_update_session_endpoint_removed(): + paths = {getattr(r, "path", "") for r in rm.router.routes} + assert "/update-session" not in paths + assert not hasattr(rm, "update_trading_session") + + +def test_mdd_paused_blocks(monkeypatch): + monkeypatch.setattr(mdd_manager, "is_paused", True, raising=False) + r = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, + strategy="swing", db=_NoWriteDB(None))) + monkeypatch.setattr(mdd_manager, "is_paused", False, raising=False) + assert r["allowed"] is False and r["risk_level"] == "BLOCKED" + + +def test_dt_daily_loss_blocks_dt_only(monkeypatch): + from app.services.daytrading.position_manager import day_trading_state as st + from app.core.config import settings + monkeypatch.setattr(mdd_manager, "is_paused", False, raising=False) + monkeypatch.setattr(st, "last_total_assets", 10_000_000.0, raising=False) + monkeypatch.setattr(st, "daily_pnl", 10_000_000.0 * (float(settings.DAY_TRADE_DAILY_LOSS_LIMIT) - 0.001), raising=False) + dt = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, + strategy="daytrading", db=_NoWriteDB(None))) + sw = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, + strategy="swing", db=_NoWriteDB(None))) + assert dt["allowed"] is False and sw["allowed"] is True + + +def test_state_lookup_failure_fails_closed(monkeypatch): + async def boom(): + raise RuntimeError("x") + monkeypatch.setattr(mdd_manager, "can_open_new_position", boom) + r = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, + strategy="swing", db=_NoWriteDB(None))) + assert r["allowed"] is False + + +# ---- R3: 스윙 일일손실·연패를 TradeHistory 실계산으로 가드 ---- +def test_swing_daily_loss_from_trade_history_blocks(monkeypatch): + async def f(_db, stock_code=None): + return -10_000_000.0 * (rm.MAX_DAILY_LOSS_RATIO + 0.01), 0 + monkeypatch.setattr(rm, "_swing_realized_today_v2", f) + r = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, + strategy="swing", db=_NoWriteDB(None))) + assert r["allowed"] is False and r["risk_level"] == "CRITICAL" + + +def test_swing_consecutive_losses_from_trade_history_blocks(monkeypatch): + async def f(_db, stock_code=None): + return 0.0, rm.MAX_CONSECUTIVE_LOSSES + monkeypatch.setattr(rm, "_swing_realized_today_v2", f) + r = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, + strategy="swing", db=_NoWriteDB(None))) + assert r["allowed"] is False and r["risk_level"] == "CRITICAL" + + +def test_swing_history_lookup_failure_fails_closed(monkeypatch): + async def boom(_db, stock_code=None): + raise RuntimeError("db down") + monkeypatch.setattr(rm, "_swing_realized_today_v2", boom) + r = asyncio.run(rm.check_risk_limits(order_value=1, account_balance=10_000_000, + strategy="swing", db=_NoWriteDB(None))) + assert r["allowed"] is False and r["risk_level"] == "BLOCKED" + + +def _rows_db(rows): + class _Res: + def all(self): + return rows + + class _DB: + async def execute(self, *_a, **_k): + return _Res() + return _DB() + + +_ROWS = [(Decimal("-100"), "A", "swing"), (Decimal("-50"), "B", "swing"), + (Decimal("-7"), "A", "mdd_reduction"), (Decimal("-9"), "A", "manual"), + (Decimal("-1"), "A", "swing"), (Decimal("30"), "A", "swing"), + (Decimal("-9"), "A", "swing")] + + +def test_swing_realized_calc_streak_is_per_stock_and_excludes_forced(): + pnl, streak = asyncio.run(_REAL_SWING_CALC(_rows_db(_ROWS), stock_code="A")) + assert pnl == -146.0 and streak == 2 # A swing: -100, -1 후 +30 에서 중단 + + +def test_swing_realized_calc_no_global_streak_without_stock(): + pnl, streak = asyncio.run(_REAL_SWING_CALC(_rows_db(_ROWS))) + assert streak == 0 and pnl == -146.0 + + +# ── R5: 리스크 상태는 mdd_manager 실상태를 반영한다 ───────────────────────── +def test_mdd_status_v2_reflects_mdd_manager(monkeypatch): + monkeypatch.setattr(mdd_manager, "peak_balance", 10_000_000.0) + monkeypatch.setattr(mdd_manager, "_last_balance", 8_900_000.0, raising=False) + monkeypatch.setattr(mdd_manager, "is_paused", True) + monkeypatch.setattr(mdd_manager, "reduction_applied", "10pct") + r = asyncio.run(rm.get_mdd_status_v2()) + assert r["is_paused"] is True and r["reduction_applied"] == "10pct" + assert r["drawdown_pct"] == 11.0 + assert "update_health" in r + + +def test_mdd_status_unknown_balance_is_none_not_zero(monkeypatch): + monkeypatch.setattr(mdd_manager, "peak_balance", 0.0) + monkeypatch.setattr(mdd_manager, "_last_balance", 0.0, raising=False) + assert rm._mdd_state_v2()["drawdown_pct"] is None + + +def test_session_today_reports_mdd_pause_when_no_session_row(monkeypatch): + monkeypatch.setattr(mdd_manager, "is_paused", True) + r = asyncio.run(rm.get_today_session(db=_NoWriteDB(None))) + assert r["is_paused"] is True and r["pause_reason"] + + +def test_risk_metrics_no_bogus_pct_level(): + from datetime import datetime + + class _T: + def __init__(self, pl, i): + self.net_profit_loss = pl + self.executed_at = datetime(2026, 10, 1, 9, i) + self.created_at = self.executed_at + + class _R: + def __init__(self, rows): + self._rows = rows + def scalars(self): + return self + def all(self): + return self._rows + + class _DB: + async def execute(self, *_a, **_k): + return _R([_T(100, 1), _T(-300, 2), _T(-50, 3)]) + + r = asyncio.run(rm.get_risk_metrics(days=30, db=_DB())) + assert r["max_drawdown_pct"] is None and r["risk_level"] is None + assert "mdd_status" in r diff --git a/backend/tests/test_risk_metrics_fill_order_r8.py b/backend/tests/test_risk_metrics_fill_order_r8.py new file mode 100644 index 0000000..a07c145 --- /dev/null +++ b/backend/tests/test_risk_metrics_fill_order_r8.py @@ -0,0 +1,38 @@ +"""R8: /risk-metrics, /sharpe-ratio 는 executed_at NULL(DT·스윙 청산·MDD) 행을 created_at 으로 정렬.""" +import asyncio +from datetime import timedelta +from unittest.mock import MagicMock + +from sqlalchemy.dialects import postgresql + +from app.api.endpoints import risk_management as rm + + +class _CaptureDB: + def __init__(self): + self.stmts = [] + + async def execute(self, stmt, *_a, **_k): + self.stmts.append(stmt) + r = MagicMock() + r.scalars.return_value.all.return_value = [] + return r + + +def _order_sql(stmt): + sql = str(stmt.compile(dialect=postgresql.dialect())) + return sql.split("ORDER BY", 1)[1].lower() + + +def test_risk_metrics_orders_by_coalesce(): + db = _CaptureDB() + asyncio.run(rm.get_risk_metrics(days=30, db=db)) + order = _order_sql(db.stmts[0]) + assert "coalesce(trade_history.executed_at, trade_history.created_at)" in order + + +def test_sharpe_orders_by_coalesce(): + db = _CaptureDB() + asyncio.run(rm.get_sharpe_ratio(days=30, db=db)) + order = _order_sql(db.stmts[0]) + assert "coalesce(trade_history.executed_at, trade_history.created_at)" in order diff --git a/backend/tests/test_round4_fixes_20261002.py b/backend/tests/test_round4_fixes_20261002.py index 4469adf..ab8b503 100644 --- a/backend/tests/test_round4_fixes_20261002.py +++ b/backend/tests/test_round4_fixes_20261002.py @@ -69,6 +69,10 @@ def sell_env(monkeypatch): monkeypatch.setattr(te, "_place_order_with_retry", _po) monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True)) monkeypatch.setattr(te, "_release_order_lock", AsyncMock(return_value=None)) + # [하드닝 R1] 매도 직전 KIS 실보유 상한·공용 청산 쿨다운 — 상한 넉넉히, 쿨다운 상태 격리 + import app.services.swing.exit_service as _es + monkeypatch.setattr(te, "_recommendation_sell_kis_cap_v2", AsyncMock(return_value=(10**6, 10**6))) + monkeypatch.setattr(_es, "_recent_exits", {}) async def _noop(*a, **k): return None @@ -78,6 +82,8 @@ def sell_env(monkeypatch): verifies.append(k) monkeypatch.setattr(te, "_verify_and_sync_execution", _cap_verify) monkeypatch.setattr(te, "_verify_and_sync_execution_v2", _cap_verify) + # [2026-10-08] 매수·매도 경로가 체결 기준 금액·손익 갱신용 v3 를 직접 호출한다(공통 종단) + monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _cap_verify) monkeypatch.setattr(dbw, "enqueue_trade_history", _noop) monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _noop) monkeypatch.setattr(dbw, "enqueue_close_active_trade", _noop) diff --git a/backend/tests/test_round5_fixes_20261002.py b/backend/tests/test_round5_fixes_20261002.py index 9464f13..e9e60bc 100644 --- a/backend/tests/test_round5_fixes_20261002.py +++ b/backend/tests/test_round5_fixes_20261002.py @@ -89,6 +89,10 @@ async def test_h3_sell_unknown_recorded_pending_not_failed(monkeypatch): monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) monkeypatch.setattr(dbw, "enqueue_trade_history", _th) monkeypatch.setattr(te.korea_investment_api, "get_current_price", AsyncMock(side_effect=AssertionError)) + # [하드닝 R1] 매도 직전 KIS 실보유 상한·공용 청산 쿨다운 — 상한 넉넉히, 쿨다운 상태 격리 + import app.services.swing.exit_service as _es + monkeypatch.setattr(te, "_recommendation_sell_kis_cap_v2", AsyncMock(return_value=(10**6, 10**6))) + monkeypatch.setattr(_es, "_recent_exits", {}) class _DB: async def execute(self, stmt): diff --git a/backend/tests/test_run_backtest_pit_universe_20261009.py b/backend/tests/test_run_backtest_pit_universe_20261009.py new file mode 100644 index 0000000..e2f095c --- /dev/null +++ b/backend/tests/test_run_backtest_pit_universe_20261009.py @@ -0,0 +1,70 @@ +"""run_backtest PIT 유니버스 순수 함수 회귀 (네트워크·DB 미사용).""" +import sys +import types +from datetime import date, timedelta + +import pytest + +if "FinanceDataReader" not in sys.modules: + try: + import FinanceDataReader # noqa: F401 + except ImportError: + sys.modules["FinanceDataReader"] = types.ModuleType("FinanceDataReader") + +import run_backtest as rb + + +@pytest.fixture(autouse=True) +def _no_fdr(monkeypatch): + def boom(*a, **k): + raise AssertionError("FDR 실호출 금지") + if hasattr(rb.fdr, "DataReader") or True: + monkeypatch.setattr(rb.fdr, "DataReader", boom, raising=False) + + +def _bar(code, d, c): + return rb.BarData(d, c, c, c, c, 1000, code, code) + + +def test_rank_includes_delisted_and_excludes_short_history(): + n = rb.PIT_MIN_HISTORY_BARS + rows = [("A", "a", 5.0, n), ("DEAD", "d", 9.0, n), ("NEW", "n", 99.0, n - 1), ("Z", "z", 0, n)] + assert [c for c, _ in rb.rank_pit_universe_v2(rows, 10)] == ["DEAD", "A"] + assert [c for c, _ in rb.rank_pit_universe_v2(rows, 1)] == ["DEAD"] + + +def test_delisted_gets_writeoff_bar_default_zero_recovery(): + end = date(2026, 3, 25) + last = end - timedelta(days=rb.PIT_DELIST_GRACE_DAYS + 40) + bars = {"DEAD": [_bar("DEAD", last, 5000)], "LIVE": [_bar("LIVE", end, 7000)]} + out = rb.apply_delisting_writeoff_v2(bars, end) + assert out == ["DEAD"] + assert len(bars["LIVE"]) == 1 + term = bars["DEAD"][-1] + assert term.trade_date == last + timedelta(days=1) + assert term.close_price == 1.0 and term.volume == 0 + + +def test_recovery_ratio_and_clamp(): + end = date(2026, 3, 25) + last = end - timedelta(days=200) + bars = {"D": [_bar("D", last, 5000)]} + rb.apply_delisting_writeoff_v2(bars, end, recovery=0.5) + assert bars["D"][-1].close_price == 2500.0 + bars = {"D": [_bar("D", last, 5000)]} + rb.apply_delisting_writeoff_v2(bars, end, recovery=7) # 상한 1.0 클램프 + assert bars["D"][-1].close_price == 5000.0 + + +def test_survivor_within_grace_not_delisted(): + end = date(2026, 3, 25) + bars = {"X": [_bar("X", end - timedelta(days=rb.PIT_DELIST_GRACE_DAYS), 100)]} + assert rb.apply_delisting_writeoff_v2(bars, end) == [] + + +def test_db_url_requires_env(monkeypatch): + monkeypatch.delenv("DATABASE_URL", raising=False) + with pytest.raises(RuntimeError): + rb._db_url_for_asyncpg_v2() + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@h/db_test") + assert rb._db_url_for_asyncpg_v2().startswith("postgresql://") diff --git a/backend/tests/test_schedulers_entry_funnel_r4.py b/backend/tests/test_schedulers_entry_funnel_r4.py new file mode 100644 index 0000000..b822948 --- /dev/null +++ b/backend/tests/test_schedulers_entry_funnel_r4.py @@ -0,0 +1,85 @@ +"""schedulers.py 하드닝 R4 — 매수 0건 조기 탐지(퍼널 집계 / 0건 경보 판정).""" +import asyncio +from datetime import date, datetime + +import pytest +import pytz + +from app.services import schedulers as S + +KST = pytz.timezone("Asia/Seoul") +WED = date(2026, 10, 7) + + +def _at(d, h, m=0): + return KST.localize(datetime(d.year, d.month, d.day, h, m)) + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch): + S._ENTRY_FUNNEL.update(day=None, blocks={}, attempts=0, last_alert_day=None) + monkeypatch.setattr(S, "now_kst", lambda: _at(WED, 11)) + + +def test_funnel_top_sorted_and_daily_reset(monkeypatch): + for g, n in (("mdd", 1), ("ml_hold", 5), ("circuit", 3)): + S.record_entry_gate_block_v2(g, n) + S.record_entry_attempt_v2() + f = S.get_entry_funnel_v2(2) + assert f["blocks_top"] == [("ml_hold", 5), ("circuit", 3)] and f["attempts"] == 1 + monkeypatch.setattr(S, "now_kst", lambda: _at(date(2026, 10, 8), 10)) + assert S.get_entry_funnel_v2()["blocks_top"] == [] + S.record_entry_gate_block_v2("mdd") + assert S._ENTRY_FUNNEL["blocks"] == {"mdd": 1} + + +def test_gate_cardinality_capped(): + for i in range(S._ENTRY_FUNNEL_MAX_GATES + 10): + S.record_entry_gate_block_v2(f"g{i}") + assert len(S._ENTRY_FUNNEL["blocks"]) <= S._ENTRY_FUNNEL_MAX_GATES + 1 + + +def test_record_never_raises(monkeypatch): + def boom(): + raise RuntimeError("x") + monkeypatch.setattr(S, "now_kst", boom) + S.record_entry_gate_block_v2("a") + S.record_entry_attempt_v2() + + +def test_zero_buy_decide(): + h = S._zero_buy_alert_hours_v2() + ok = _at(WED, 13) + assert S._zero_buy_decide_v2(ok, 0, "bull", None, h) + assert not S._zero_buy_decide_v2(ok, 1, "bull", None, h) + assert not S._zero_buy_decide_v2(ok, 0, "bear", None, h) + assert not S._zero_buy_decide_v2(ok, 0, "bull", WED, h) + assert not S._zero_buy_decide_v2(_at(WED, 10), 0, "bull", None, h) + assert not S._zero_buy_decide_v2(_at(WED, 16), 0, "bull", None, h) + assert not S._zero_buy_decide_v2(_at(date(2026, 10, 10), 13), 0, "bull", None, h) + + +def test_alert_hours_uses_settings(monkeypatch): + from types import SimpleNamespace + monkeypatch.setattr(S, "settings", SimpleNamespace(ZERO_BUY_ALERT_HOURS=1.5)) + assert S._zero_buy_alert_hours_v2() == 1.5 + monkeypatch.setattr(S, "settings", SimpleNamespace(ZERO_BUY_ALERT_HOURS=-1)) + assert S._zero_buy_alert_hours_v2() == 3.0 + + +def test_count_today_buys_groups_and_defaults_swing(): + class R: + def all(self): + return [(None, 2), ("daytrading", 3)] + + class DB: + async def execute(self, q): + return R() + out = asyncio.run(S._count_today_buys_v2(DB())) + assert out == {"swing": 2, "daytrading": 3} + + +def test_format_includes_buys_and_top(): + S.record_entry_gate_block_v2("ml_hold", 4) + t = S._format_funnel_v2({"swing": 0}) + assert "BUY 0건" in t and "ml_hold" in t diff --git a/backend/tests/test_schedulers_hardening_r1.py b/backend/tests/test_schedulers_hardening_r1.py new file mode 100644 index 0000000..e1d332e --- /dev/null +++ b/backend/tests/test_schedulers_hardening_r1.py @@ -0,0 +1,246 @@ +"""schedulers.py 하드닝 R1 — 마감 후 확정 사이클 결정 / KRX 일봉 완전성 점검.""" +import asyncio +from datetime import date, datetime, timedelta + +import pytest +import pytz + +from app.services import schedulers as S + +KST = pytz.timezone("Asia/Seoul") + + +def _at(d, h, m): + return KST.localize(datetime(d.year, d.month, d.day, h, m)) + + +WED = date(2026, 10, 7) # 수요일 (평일) +SAT = date(2026, 10, 10) + + +def test_final_cycle_waits_until_1535(): + assert S._batch_final_cycle_decide_v2(_at(WED, 15, 31), None)[0] == "wait" + + +def test_final_cycle_runs_after_1535_once(): + assert S._batch_final_cycle_decide_v2(_at(WED, 15, 40), None)[0] == "final" + assert S._batch_final_cycle_decide_v2(_at(WED, 15, 40), WED)[0] == "sleep_tomorrow" + + +def test_final_cycle_gives_up_after_1800_and_weekend(): + assert S._batch_final_cycle_decide_v2(_at(WED, 18, 0), None)[0] == "sleep_tomorrow" + assert S._batch_final_cycle_decide_v2(_at(SAT, 15, 40), None)[0] == "sleep_tomorrow" + + +def test_completeness_flags_partial_day_and_lag(): + rows = {date(2026, 10, 5): 2560, date(2026, 10, 6): 1670, date(2026, 10, 7): 2560} + r = S._evaluate_krx_completeness_v2(rows, date(2026, 10, 8)) + assert not r["ok"] + assert any("부분 수집" in i for i in r["issues"]) + assert any("기대" in i for i in r["issues"]) + + +def test_completeness_flags_missing_trading_day(): + rows = {date(2026, 10, 5): 2560, date(2026, 10, 7): 2560} + r = S._evaluate_krx_completeness_v2(rows, date(2026, 10, 7)) + assert any("누락" in i for i in r["issues"]) + + +def test_completeness_ok_and_empty(): + rows = {date(2026, 10, 6): 2560, date(2026, 10, 7): 2550} + assert S._evaluate_krx_completeness_v2(rows, date(2026, 10, 7))["ok"] + assert not S._evaluate_krx_completeness_v2({}, WED)["ok"] + + +def test_expected_date_skips_weekend(): + assert S._expected_last_trade_date_v2(date(2026, 10, 19)) == date(2026, 10, 16) # 월 -> 직전 금 + + +def test_collector_decide_morning_then_evening(): + assert S._krx_collector_decide_v2(_at(WED, 9, 5), None, None)[0] == "morning" + assert S._krx_collector_decide_v2(_at(WED, 9, 5), None, WED)[0] == "sleep" + assert S._krx_collector_decide_v2(_at(WED, 15, 45), None, WED)[0] == "evening" + a, sec = S._krx_collector_decide_v2(_at(WED, 15, 45), WED, WED) + assert a == "sleep" and sec > 3600 * 10 # 익일 09:00 까지 + assert S._krx_collector_decide_v2(_at(SAT, 10, 0), None, None)[0] == "sleep" + + +def test_check_failure_is_not_ok_and_alerts(monkeypatch): + async def boom(): + raise RuntimeError("db down") + + sent = [] + + class _T: + async def notify_system_warning(self, c, m): + sent.append((c, m)) + + import app.services.telegram_notifier as tn + monkeypatch.setattr(S, "_fetch_krx_row_counts_v2", boom) + monkeypatch.setattr(tn, "telegram_notifier", _T()) + r = asyncio.run(S._check_krx_data_v2(WED, alert=True)) + assert r["ok"] is False + assert S.get_krx_data_health_v2()["ok"] is False + assert sent and sent[0][0] == "KRX_DATA" + + +# ── [하드닝 R2] 배치 분석 타임아웃/경보/헬스 ────────────────────────────────── +def test_batch_fetch_times_out_on_hang(): + async def hang(): + await asyncio.sleep(3600) + + with pytest.raises(asyncio.TimeoutError): + asyncio.run(S._batch_fetch_v2(hang, timeout=0.05)) + + +def test_batch_timeout_reads_settings(monkeypatch): + import types + monkeypatch.setattr(S, "settings", types.SimpleNamespace(BATCH_ANALYSIS_TIMEOUT_SEC=123)) + assert S._batch_timeout_sec_v2() == 123.0 + monkeypatch.setattr(S, "settings", types.SimpleNamespace(BATCH_ANALYSIS_TIMEOUT_SEC=-1)) + assert S._batch_timeout_sec_v2() == 900.0 + monkeypatch.setattr(S, "settings", types.SimpleNamespace()) + assert S._batch_timeout_sec_v2() == 900.0 + + +def test_batch_failure_alerts_then_throttles_and_ok_resets(monkeypatch): + sent = [] + + class _T: + async def notify_system_warning(self, c, m): + sent.append((c, m)) + + import app.services.telegram_notifier as tn + from app.core import heartbeat as hb + monkeypatch.setattr(tn, "telegram_notifier", _T()) + S._BATCH_HEALTH.update(consecutive_failed_cycles=0, last_error=None) + hb.reset() + + asyncio.run(S._batch_cycle_failed_v2(RuntimeError("x"), 3)) + asyncio.run(S._batch_cycle_failed_v2(RuntimeError("x"), 3)) + assert len(sent) == 1 and sent[0][0] == "BATCH_ANALYSIS" + h = S.get_batch_analysis_health_v2() + assert h["consecutive_failed_cycles"] == 2 and "RuntimeError" in h["last_error"] + + S._batch_cycle_ok_v2(198) + h = S.get_batch_analysis_health_v2() + assert h["consecutive_failed_cycles"] == 0 and h["last_ok_count"] == 198 + assert hb.age_seconds("batch_analysis") is not None + hb.reset() + + +def test_batch_alert_failure_does_not_raise(monkeypatch): + class _T: + async def notify_system_warning(self, c, m): + raise RuntimeError("tg down") + + import app.services.telegram_notifier as tn + monkeypatch.setattr(tn, "telegram_notifier", _T()) + S._BATCH_HEALTH.update(consecutive_failed_cycles=0) + asyncio.run(S._batch_cycle_failed_v2(RuntimeError("x"), 3)) + S._BATCH_HEALTH.update(consecutive_failed_cycles=0) + + +def test_completeness_flags_old_partial_day_beyond_five_days(): + """5거래일 밖(과거) 부분 수집일도 경보 대상이어야 한다.""" + rows = {date(2026, 9, 14) + timedelta(days=i): 2560 for i in range(0, 14) if (date(2026, 9, 14) + timedelta(days=i)).weekday() < 5} + first = min(rows) + rows[first] = 1672 + last = max(rows) + r = S._evaluate_krx_completeness_v2(rows, last) + assert any(str(first) in i and "부분 수집" in i for i in r["issues"]) + assert S._KRX_CHECK_RECENT >= S._KRX_COMPLETE_WINDOW + + +def test_morning_expected_date_lags_one_more_trading_day(): + thu = date(2026, 10, 8) + assert S._expected_trade_date_lag_v2(thu, 1) == S._expected_last_trade_date_v2(thu) + assert S._expected_trade_date_lag_v2(thu, S._KRX_MORNING_LAG) == date(2026, 10, 6) + # T+1 미발행(직전 거래일 0행) 상태에서 아침 점검은 정상 + rows = {date(2026, 10, 5): 2560, date(2026, 10, 6): 2560} + r = S._evaluate_krx_completeness_v2(rows, S._expected_trade_date_lag_v2(thu, S._KRX_MORNING_LAG)) + assert r["ok"] + + +def test_weekly_rescan_due_only_monday_once(): + mon, tue = date(2026, 10, 5), date(2026, 10, 6) + assert S._krx_rescan_due_v2(mon, None) + assert not S._krx_rescan_due_v2(mon, mon) + assert not S._krx_rescan_due_v2(tue, None) + + +def test_close_reconcile_rows_use_krx_close(): + rows = [ + {"stock_code": "006260", "stock_name": "LS", "analysis_date": WED, "current_price": 281000, "rsi": 50}, + {"stock_code": "000001", "stock_name": "A", "analysis_date": WED, "current_price": 5000}, + {"stock_code": "000002", "stock_name": "B", "analysis_date": WED, "current_price": 7000}, + ] + krx = {"006260": (280000, None, 100), "000001": (5000, None, None), "000002": (0, None, None)} + out = S._build_close_reconcile_rows_v2(rows, krx) + assert [r["stock_code"] for r in out] == ["006260"] + assert out[0]["current_price"] == 280000 and out[0]["volume"] == 100 + assert out[0]["rsi"] == 50 and "updated_at" in out[0] + + +def test_reconcile_failure_does_not_enqueue_when_db_raises(monkeypatch): + class Boom: + async def __aenter__(self): + raise RuntimeError("db blocked") + async def __aexit__(self, *a): + return False + monkeypatch.setattr(S, "AsyncSessionLocal", lambda: Boom()) + with pytest.raises(RuntimeError): + asyncio.run(S._reconcile_prev_analysis_close_v2(WED)) + + +# ── 라운드 7: 보정 건너뛰기 조건 / 과거 부분일 분리 / 휴장일 수집 ────────────────── +def test_reconcile_v3_updates_volume_and_rate_when_price_equal(): + rows = [ + {"stock_code": "A", "current_price": 5000, "volume": 60, "change_rate": 1.0}, + {"stock_code": "B", "current_price": 5000, "volume": 100, "change_rate": 2.0}, + ] + krx = {"A": (5000, 2.5, 100), "B": (5000, 2.0, 100)} + out = S._build_close_reconcile_rows_v3(rows, krx) + assert [r["stock_code"] for r in out] == ["A"] + assert out[0]["volume"] == 100 and out[0]["change_rate"] == 2.5 + + +def test_evaluator_splits_stale_from_fresh(): + rows = {date(2026, 10, 5): 2560, date(2026, 10, 6): 1670, date(2026, 10, 7): 2560} + r = S._evaluate_krx_completeness_v2(rows, date(2026, 10, 7)) + assert not r["ok"] and r["fresh_ok"] and r["stale_issues"] + r2 = S._evaluate_krx_completeness_v2({date(2026, 10, 6): 2560}, date(2026, 10, 7)) + assert not r2["fresh_ok"] + + +def test_stale_only_alert_sent_once_per_day(monkeypatch): + counts = {date(2026, 10, 5): 2560, date(2026, 10, 6): 1670, date(2026, 10, 7): 2560} + sent = [] + + async def fake(): + return counts + + class N: + async def notify_system_warning(self, k, m): + sent.append(m) + import app.services.telegram_notifier as T + monkeypatch.setattr(S, "_fetch_krx_row_counts_v2", fake) + monkeypatch.setattr(T, "telegram_notifier", N()) + S._KRX_STALE_ALERT_STATE["day"] = None + r = asyncio.run(S._check_krx_data_v2(date(2026, 10, 8), alert=False, lag=1, alert_stale=True)) + asyncio.run(S._check_krx_data_v2(date(2026, 10, 8), alert=False, lag=1, alert_stale=True)) + assert r["fresh_ok"] and len(sent) == 1 + + +def test_behind_trading_days(): + from app.core.market_schedule import is_krx_holiday + exp = sum(1 for d in (date(2026, 10, 8), date(2026, 10, 9)) if not is_krx_holiday(d)) + assert S._krx_behind_days_v2(date(2026, 10, 7), date(2026, 10, 12)) == exp + assert S._krx_behind_days_v2(None, WED) is None + + +def test_offday_collect_once_at_noon(): + assert S._krx_collector_decide_v3(_at(SAT, 11, 0), None, None, None)[0] == "sleep" + assert S._krx_collector_decide_v3(_at(SAT, 12, 5), None, None, None)[0] == "offday" + assert S._krx_collector_decide_v3(_at(SAT, 12, 5), None, None, SAT)[0] == "sleep" + assert S._krx_collector_decide_v3(_at(WED, 9, 5), None, None)[0] == "morning" diff --git a/backend/tests/test_sector_mapper_cache_health_r3.py b/backend/tests/test_sector_mapper_cache_health_r3.py new file mode 100644 index 0000000..7e0ef08 --- /dev/null +++ b/backend/tests/test_sector_mapper_cache_health_r3.py @@ -0,0 +1,68 @@ +"""R3: 섹터 캐시 건강도·자동 갱신 회귀 테스트 (네트워크는 예외 발생형 목).""" +import asyncio +import json +import logging +from datetime import datetime, timedelta, timezone + +import pytest + +from app.services import sector_mapper as sm + + +@pytest.fixture(autouse=True) +def _iso(monkeypatch, tmp_path): + monkeypatch.setenv("AUTOSTOCK_SECTOR_CACHE_PATH", str(tmp_path / "sector_map.json")) + monkeypatch.setattr(sm, "_cache_payload", {}) + monkeypatch.setattr(sm, "_cache_sectors", {}) + monkeypatch.setattr(sm, "_cache_industries", {}) + monkeypatch.setattr(sm, "_cache_mtime", -1.0) + + def _boom(*a, **k): + raise AssertionError("network blocked") + monkeypatch.setattr(sm, "_fetch_json", _boom) + + +def _write(tmp_path, days_old): + ts = (datetime.now(timezone.utc) - timedelta(days=days_old)).isoformat() + (tmp_path / "sector_map.json").write_text( + json.dumps({"updated_at": ts, "sectors": {"000001": "finance"}}), encoding="utf-8") + + +def test_missing_cache_is_unhealthy_and_logs_error(caplog): + with caplog.at_level(logging.ERROR): + h = sm.log_cache_health_v2() + assert not h["ok"] and h["reason"] == "missing" + assert any("섹터 캐시" in r.message for r in caplog.records) + + +def test_fresh_cache_ok(tmp_path): + _write(tmp_path, 1) + h = sm.get_cache_health_v2() + assert h["ok"] and not h["stale"] and h["stocks"] == 1 + + +def test_stale_cache_flagged(tmp_path): + _write(tmp_path, sm.SECTOR_CACHE_MAX_AGE_DAYS + 2) + h = sm.get_cache_health_v2() + assert not h["ok"] and h["reason"] == "stale" + + +def test_next_delay_skips_weekend(): + kst = timezone(timedelta(hours=9)) + fri_after = datetime(2026, 10, 9, 9, 0, tzinfo=kst) # 금요일 09:00 + d = sm._next_refresh_delay_v2(fri_after) + target = fri_after + timedelta(seconds=d) + assert target.weekday() == 0 and (target.hour, target.minute) == (8, 30) + + +def test_scheduler_refreshes_immediately_when_missing(monkeypatch): + calls = [] + + async def fake_refresh(**kw): + calls.append(1) + raise asyncio.CancelledError # 1회 호출 후 루프 종료 + + monkeypatch.setattr(sm, "refresh_sector_map", fake_refresh) + with pytest.raises(asyncio.CancelledError): + asyncio.run(sm.sector_map_refresh_scheduler()) + assert calls == [1] diff --git a/backend/tests/test_sell_active_trade_target_r8.py b/backend/tests/test_sell_active_trade_target_r8.py new file mode 100644 index 0000000..7a948a1 --- /dev/null +++ b/backend/tests/test_sell_active_trade_target_r8.py @@ -0,0 +1,89 @@ +"""[하드닝 R8] 추천 경로 매도의 ActiveTrade 대상 역추적. + +역추적 SELECT 예외 시 로그 없이 'swing' 이름으로 폴백하면, 실제 행이 'synced' 일 때 +close/upsert 매칭이 0건 → 유령 active 행 + remaining_pct 미갱신(분할 단계 재발동). +예외 시에는 warning 을 남기고 비-DT 최신 행 필터(strategy_name_not)로 겨냥해야 한다. +""" +import logging +from types import SimpleNamespace + +import pytest + +from app.services import trade_executor as te +from app.services.db_worker import DT_STRATEGY_NAMES + + +class _DBRaises: + async def execute(self, *_a, **_k): + raise RuntimeError("db down") + + +class _DBReturns: + def __init__(self, row): + self._row = row + + async def execute(self, *_a, **_k): + row = self._row + + class _R: + def scalars(self_inner): + return SimpleNamespace(first=lambda: row) + + return _R() + + +class _DBMustNotBeCalled: + async def execute(self, *_a, **_k): + raise AssertionError("strategy_name 이 있으면 역추적 SELECT 를 하지 않아야 한다") + + +@pytest.mark.asyncio +async def test_lookup_exception_targets_non_dt_row_and_warns(caplog): + caplog.set_level(logging.WARNING, logger=te.logger.name) + name, filt = await te._resolve_sell_active_trade_target_v2( + _DBRaises(), {"strategy_type": "swing"}, "323410" + ) + assert filt is True, "예외 시 'swing' 이름 폴백 → synced 행 매칭 0건(유령 행)" + assert name is None + assert any("역추적 실패" in r.getMessage() and r.levelno >= logging.WARNING + for r in caplog.records), "예외 흔적이 warning 이상으로 남아야 한다(§4)" + + +@pytest.mark.asyncio +async def test_lookup_exception_keeps_dt_tagged_holding_on_dt_name(): + dt = DT_STRATEGY_NAMES[0] + name, filt = await te._resolve_sell_active_trade_target_v2( + _DBRaises(), {"strategy_type": dt}, "005930" + ) + assert (name, filt) == (dt, False) + + +@pytest.mark.asyncio +async def test_lookup_finds_synced_row_name(): + name, filt = await te._resolve_sell_active_trade_target_v2( + _DBReturns(SimpleNamespace(strategy_name="synced")), {}, "005930" + ) + assert (name, filt) == ("synced", False) + + +@pytest.mark.asyncio +async def test_lookup_null_name_row_uses_non_dt_filter(): + name, filt = await te._resolve_sell_active_trade_target_v2( + _DBReturns(SimpleNamespace(strategy_name=None)), {"strategy_type": "swing"}, "005930" + ) + assert (name, filt) == (None, True) + + +@pytest.mark.asyncio +async def test_explicit_strategy_name_skips_lookup(): + name, filt = await te._resolve_sell_active_trade_target_v2( + _DBMustNotBeCalled(), {"strategy_name": "MOMENTUM_BREAKOUT"}, "005930" + ) + assert (name, filt) == ("MOMENTUM_BREAKOUT", False) + + +def test_execute_sell_uses_resolver(): + import inspect + src = inspect.getsource(te._execute_sell) + assert "_resolve_sell_active_trade_target_v2(" in src + assert "_at_filter_not_dt = False\n" not in src.split("_resolve_sell_active_trade_target_v2(")[1][:400] diff --git a/backend/tests/test_sideways_message_r6.py b/backend/tests/test_sideways_message_r6.py new file mode 100644 index 0000000..17f2452 --- /dev/null +++ b/backend/tests/test_sideways_message_r6.py @@ -0,0 +1,22 @@ +from app.core.config import settings +from app.services.market_analyzer import _sideways_message_v2 + + +def test_rule_message_uses_effective_thresholds(): + msg = _sideways_message_v2(2500.0, 2400.0, False) + enter = settings.MEAN_REVERSION_ADX_SIDEWAYS - 2.0 + exit_ = settings.MEAN_REVERSION_ADX_SIDEWAYS + 2.0 + assert f"ADX<{enter:g}" in msg and f">{exit_:g}" in msg + assert f"{settings.MEAN_REVERSION_SLOPE_THRESHOLD:g}" in msg + assert "AND" in msg and "또는" not in msg and "HMM" not in msg + + +def test_hmm_override_message_names_hmm(monkeypatch): + monkeypatch.setattr(settings, "MEAN_REVERSION_ADX_SIDEWAYS", 30.0) + msg = _sideways_message_v2(2500.0, 2400.0, True) + assert "HMM" in msg and "ADX<" not in msg + + +def test_message_follows_settings(monkeypatch): + monkeypatch.setattr(settings, "MEAN_REVERSION_ADX_SIDEWAYS", 26.0) + assert "ADX<24" in _sideways_message_v2(1, 1, False) diff --git a/backend/tests/test_strategy_config_dt_readonly_20261009.py b/backend/tests/test_strategy_config_dt_readonly_20261009.py new file mode 100644 index 0000000..4b1c3a1 --- /dev/null +++ b/backend/tests/test_strategy_config_dt_readonly_20261009.py @@ -0,0 +1,147 @@ +"""DAY_TRADE 그룹은 settings 단일 소스 — strategy_configs 쓰기·캐시 노출·시드 차단.""" +import asyncio +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from app.core.config import settings +from app.services import strategy_config_service as scs + + +class _Boom: + def __call__(self, *a, **k): + raise AssertionError("DB 세션이 열리면 안 된다") + + +@pytest.fixture(autouse=True) +def _no_db(monkeypatch): + monkeypatch.setattr(scs, "AsyncSessionLocal", _Boom()) + + +def test_dt_keys_are_read_only(): + dt = [k for k, v in scs.DEFAULT_PARAMS.items() if v[0] == "DAY_TRADE"] + assert len(dt) >= 18 + assert set(dt) <= scs.READ_ONLY_KEYS + assert "STOP_LOSS_PERCENT" not in scs.READ_ONLY_KEYS # 실매매 배선 키는 편집 가능 유지 + + +@pytest.mark.parametrize("key", ["DAY_TRADE_ENABLED", "DAY_TRADE_RSI_BREAKEVEN_THRESHOLD", "DAY_TRADE_EOD_HOUR"]) +def test_upsert_and_reset_rejected_before_db(key): + svc = scs.StrategyConfigService() + with pytest.raises(ValueError): + asyncio.run(svc.upsert(key, "0")) + with pytest.raises(ValueError): + asyncio.run(svc.reset_to_default(key)) + + +def test_cache_excludes_dt_rows(monkeypatch): + rows = [ + SimpleNamespace(key="DAY_TRADE_ENABLED", group="DAY_TRADE", value="False", default_value="True", value_type="bool"), + SimpleNamespace(key="MAX_POSITION_SIZE", group="RISK", value="0.04", default_value="0.04", value_type="float"), + SimpleNamespace(key="STOP_LOSS_PERCENT", group="RISK", value="-8.0", default_value="-7.0", value_type="float"), + ] + + class _Sess: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *_): + r = MagicMock() + r.scalars.return_value.all.return_value = rows + return r + + monkeypatch.setattr(scs, "AsyncSessionLocal", lambda: _Sess()) + svc = scs.StrategyConfigService() + asyncio.run(svc._refresh()) + assert "DAY_TRADE_ENABLED" not in svc._cache + assert "MAX_POSITION_SIZE" not in svc._cache # settings 단일 소스 + assert svc._cache["STOP_LOSS_PERCENT"] == -8.0 + + +def test_effective_values_come_from_settings(): + eff = scs.get_effective_dt_params_v2() + assert eff + assert all(getattr(settings, scs._effective_settings_key_v2(k)) == v for k, v in eff.items()) + + +def test_serialize_marks_dt_read_only_and_shows_settings_value(): + row = SimpleNamespace(id=1, key="DAY_TRADE_RSI_BB_RSI_THRESHOLD", group="DAY_TRADE", label="l", + description=None, value_type="float", value="40.0", default_value="40.0", + min_value=None, max_value=None, is_active=True, updated_by="x") + d = scs.serialize_config_v2(row) + assert d["read_only"] is True + assert d["value"] == str(settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD) + + +def test_serialize_non_dt_passthrough(): + row = SimpleNamespace(id=2, key="STOP_LOSS_PERCENT", group="RISK", label="l", description=None, + value_type="float", value="-7.0", default_value="-7.0", + min_value=None, max_value=None, is_active=True, updated_by=None) + d = scs.serialize_config_v2(row) + assert d["read_only"] is False and d["value"] == "-7.0" + + +def test_reset_endpoint_returns_422_for_dt_key(): + """reset 엔드포인트가 DT 키 ValueError 를 500 이 아니라 422 로 변환.""" + from fastapi import HTTPException + from app.api.endpoints import strategy_config as ep + + with pytest.raises(HTTPException) as ei: + asyncio.run(ep.reset_config("DAY_TRADE_ENABLED")) + assert ei.value.status_code == 422 + + +@pytest.mark.parametrize("disp,eng", sorted(scs.DT_EFFECTIVE_KEY_ALIASES.items())) +def test_alias_keys_show_engine_effective_value(disp, eng): + cfg = SimpleNamespace( + id=1, key=disp, group="DAY_TRADE", label="x", description="d", value_type="float", + value="999", default_value="999", min_value=None, max_value=None, is_active=True, updated_by="u", + ) + d = scs.serialize_config_v2(cfg) + assert d["value"] == str(getattr(settings, eng)) + assert scs.get_effective_dt_params_v2()[disp] == getattr(settings, eng) + + +def test_rsi_bb_display_is_gate_value_and_stop_loss_label(): + cfg = SimpleNamespace( + id=1, key="DAY_TRADE_RSI_BREAKEVEN_THRESHOLD", group="DAY_TRADE", label="x", description="d", + value_type="float", value="40.0", default_value="40.0", min_value=None, max_value=None, + is_active=True, updated_by=None, + ) + assert scs.serialize_config_v2(cfg)["value"] == str(settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD) + sl = SimpleNamespace(**{**cfg.__dict__, "key": "DAY_TRADE_STOP_LOSS_PCT"}) + assert scs.serialize_config_v2(sl)["label"] == "복원 폴백 손절" + + +_RISK_UNWIRED = ["MAX_POSITION_SIZE", "MAX_DAILY_TRADES", "MAX_HOLDINGS_COUNT", "MIN_PRICE_KRW", "MIN_PRICE_USD", + "CONSECUTIVE_LOSS_BAN_COUNT", "CONSECUTIVE_LOSS_BAN_DAYS"] + + +@pytest.mark.parametrize("key", _RISK_UNWIRED) +def test_risk_unwired_keys_read_only_and_rejected_before_db(key): + assert key in scs.READ_ONLY_KEYS + svc = scs.StrategyConfigService() + with pytest.raises(ValueError): + asyncio.run(svc.upsert(key, "1")) + with pytest.raises(ValueError): + asyncio.run(svc.reset_to_default(key)) + + +@pytest.mark.parametrize("key", _RISK_UNWIRED) +def test_risk_unwired_serialize_shows_settings_value(key): + row = SimpleNamespace(id=3, key=key, group="RISK", label="l", description=None, value_type="float", + value="0.02", default_value="0.02", min_value=None, max_value=None, + is_active=True, updated_by="ui") + d = scs.serialize_config_v2(row) + assert d["read_only"] is True + assert d["value"] == str(getattr(settings, key)) + + +def test_wired_risk_keys_remain_editable(): + for k in ("STOP_LOSS_PERCENT", "STOP_LOSS_HIGH_VOL", "ATR_STOP_MULTIPLIER", "ATR_TRAILING_MULTIPLIER", + "MDD_PAUSE_THRESHOLD", "MDD_FORCE_THRESHOLD"): + assert k not in scs.READ_ONLY_KEYS diff --git a/backend/tests/test_strategy_config_refresh_guard_20261009.py b/backend/tests/test_strategy_config_refresh_guard_20261009.py new file mode 100644 index 0000000..f085656 --- /dev/null +++ b/backend/tests/test_strategy_config_refresh_guard_20261009.py @@ -0,0 +1,43 @@ +import asyncio +import pytest +from app.services import strategy_config_service as scs + + +class _Boom: + def __init__(self, counter): + self.counter = counter + + async def __aenter__(self): + self.counter.append(1) + await asyncio.sleep(0.01) + raise RuntimeError("db down") + + async def __aexit__(self, *a): + return False + + +@pytest.mark.asyncio +async def test_inflight_guard_and_backoff(monkeypatch): + calls = [] + monkeypatch.setattr(scs, "AsyncSessionLocal", lambda: _Boom(calls)) + svc = scs.StrategyConfigService() + for _ in range(50): + svc.get("X") + await asyncio.sleep(0.1) + assert len(calls) == 1 + for _ in range(50): + svc.get("X") + await asyncio.sleep(0.05) + assert len(calls) == 1 # 실패 후 TTL 내 재시도 없음 + assert svc._consecutive_failures == 1 + assert svc.get_cache_health_v2()["stale_alarm"] is True + + +@pytest.mark.asyncio +async def test_stale_error_log(monkeypatch, caplog): + monkeypatch.setattr(scs, "AsyncSessionLocal", lambda: _Boom([])) + svc = scs.StrategyConfigService() + svc._last_success_ts = scs.time.monotonic() - svc.STALE_ERROR_SEC - 1 + with caplog.at_level("ERROR"): + await svc._refresh() + assert any("낡음" in r.message for r in caplog.records) diff --git a/backend/tests/test_swing_entry_hardening_r2_20261009.py b/backend/tests/test_swing_entry_hardening_r2_20261009.py new file mode 100644 index 0000000..61f348b --- /dev/null +++ b/backend/tests/test_swing_entry_hardening_r2_20261009.py @@ -0,0 +1,243 @@ +"""swing/entry_service 하드닝 루프 라운드 2 회귀 테스트 (2026-10-09). + + H KIS get_daily_chart(FHKST01010400)는 30봉 상한 → 눌림목(60봉)·박스권(40봉)이 구조적 미발화. + 듀얼·박스권 스캔은 네이버 → KIS 기간 조회(FHKST03010100) → 기존 경로 순으로 일봉을 확보한다. + L 국내 가격 필터 경계 통일: MIN_PRICE_KRW '이하' 거부(§5.1 '>5,000원'). + +규약(CLAUDE.md §4.1): 도달하면 안 되는 경로의 목은 예외 발생형(_Reached, BaseException), +기대값은 settings.* 참조, 네이버(외부 HTTP)는 conftest 차단 대상이 아니므로 전부 목킹. +""" +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import app.services.mean_reversion_strategy as mr +import app.services.naver_finance as nf +from app.core.config import settings +from app.core.timeutil import today_kst + + +class _Reached(BaseException): + """도달하면 안 되는(또는 도달 확인용) 관문 — 프로덕션 except Exception 에 삼켜지지 않는다.""" + + +def _bars(n: int, end_today: bool = True, start_close: float = 10000.0) -> list[dict]: + """오래된 → 최신 순 KIS 필드명 일봉 n개. 마지막 봉이 오늘(KST) 또는 어제.""" + last = today_kst() if end_today else today_kst() - timedelta(days=1) + out = [] + for i in range(n): + d = last - timedelta(days=n - 1 - i) + c = start_close + i * 10 + out.append({ + "stck_bsop_date": d.strftime("%Y%m%d"), + "stck_oprc": str(c - 5), "stck_hgpr": str(c + 10), + "stck_lwpr": str(c - 10), "stck_clpr": str(c), "acml_vol": "100000", + }) + return out + + +def _api(legacy_rows=None, range_rows=None, legacy_raises=False): + api = MagicMock() + api._quote_delegate = lambda: None + if legacy_raises: + api.get_daily_chart = AsyncMock(side_effect=_Reached("legacy get_daily_chart")) + else: + api.get_daily_chart = AsyncMock(return_value={"output2": legacy_rows or []}) + api.get_daily_ohlcv_range = AsyncMock(return_value=range_rows or []) + return api + + +# ═══════════════════════════════════════════════════════════════════════════ +# H — _fetch_scan_daily_bars_v2 +# ═══════════════════════════════════════════════════════════════════════════ + +def test_kis_daily_price_30_rows_cannot_fire_pullback(): + """[결함 재현] 30봉으로는 눌림목 v2 가 길이 검사에서 무조건 None.""" + closes = [float(b["stck_clpr"]) for b in _bars(30)] + assert mr.check_pullback_buy_signal_v2( + closes=closes, current_close=closes[-1], current_volume=10**9, + rsi=50.0, rsi_prev=40.0, volumes=[10**5] * 30, exclude_today_volume=True, + ) is None + + +@pytest.mark.asyncio +async def test_fetch_prefers_naver_when_enough_bars(monkeypatch): + from app.services.swing import entry_service as es + nv = _bars(100) + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=nv)) + api = _api(legacy_raises=True) + api.get_daily_ohlcv_range = AsyncMock(side_effect=_Reached("kis range")) + bars, src = await es._fetch_scan_daily_bars_v2(api, "005930", es.DUAL_SCAN_MIN_BARS) + assert src == "naver" + assert len(bars) >= es.DUAL_SCAN_MIN_BARS + assert bars[-1]["stck_bsop_date"] == today_kst().strftime("%Y%m%d") + + +@pytest.mark.asyncio +async def test_fetch_falls_back_to_kis_range_sorted_oldest_first(monkeypatch): + from app.services.swing import entry_service as es + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=None)) + newest_first = list(reversed(_bars(90))) + api = _api(range_rows=newest_first, legacy_raises=True) + bars, src = await es._fetch_scan_daily_bars_v2(api, "005930", es.DUAL_SCAN_MIN_BARS) + assert src == "kis_range" + assert len(bars) == 90 + dates = [b["stck_bsop_date"] for b in bars] + assert dates == sorted(dates) + assert dates[-1] == today_kst().strftime("%Y%m%d") + + +@pytest.mark.asyncio +async def test_fetch_rejects_source_without_today_bar(monkeypatch): + """오늘 봉이 빠진 소스는 쓰지 않는다 — 어제 종가로 신호·주문가가 정해지는 것을 막는다.""" + from app.services.swing import entry_service as es + monkeypatch.setattr(nf, "get_naver_daily_chart", + AsyncMock(return_value=_bars(100, end_today=False))) + api = _api(range_rows=list(reversed(_bars(90))), legacy_raises=True) + bars, src = await es._fetch_scan_daily_bars_v2(api, "005930", es.DUAL_SCAN_MIN_BARS) + assert src == "kis_range" + assert bars[-1]["stck_bsop_date"] == today_kst().strftime("%Y%m%d") + + +@pytest.mark.asyncio +async def test_fetch_uses_legacy_when_both_sources_fail(monkeypatch): + from app.services.swing import entry_service as es + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(side_effect=RuntimeError("naver down"))) + legacy_newest_first = list(reversed(_bars(30))) + api = _api(legacy_rows=legacy_newest_first) + api.get_daily_ohlcv_range = AsyncMock(side_effect=RuntimeError("kis down")) + bars, src = await es._fetch_scan_daily_bars_v2(api, "005930", es.DUAL_SCAN_MIN_BARS) + assert src == "kis_daily_price" + assert len(bars) == 30 + assert [b["stck_bsop_date"] for b in bars] == sorted(b["stck_bsop_date"] for b in bars) + + +@pytest.mark.asyncio +async def test_fetch_short_but_today_prefers_longer_source_over_legacy(monkeypatch): + """신규 상장 등으로 두 소스 모두 min_bars 미달이면 긴 쪽 — 기존 30봉 경로로 내려가지 않는다.""" + from app.services.swing import entry_service as es + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=_bars(45))) + api = _api(range_rows=list(reversed(_bars(50))), legacy_raises=True) + bars, src = await es._fetch_scan_daily_bars_v2(api, "005930", es.DUAL_SCAN_MIN_BARS) + assert (src, len(bars)) == ("kis_range", 50) + + +# ═══════════════════════════════════════════════════════════════════════════ +# 듀얼 스캔 통합 — 실제 scan_dual_strategy_signals 호출 +# ═══════════════════════════════════════════════════════════════════════════ + +class _DualDB: + def __init__(self, candidates): + self._candidates = candidates + + async def execute(self, stmt): + r = MagicMock() + r.fetchall.return_value = [] + r.scalars.return_value.all.return_value = list(self._candidates) + return r + + +@pytest.fixture +def dual_env(monkeypatch): + import app.core.market_schedule as ms + import app.services.korea_investment_api as kia + import app.services.swing.entry_service as es + import app.services.trade_executor as te + import app.services.adaptive_strategy as ad + + monkeypatch.setattr(settings, "SWING_INDEX_BREAKER_ENABLED", False) + monkeypatch.setattr(settings, "MOMENTUM_BREAKOUT_ENABLED", True) + monkeypatch.setattr(ms, "is_market_open", lambda: True) + monkeypatch.setattr(es, "is_entry_allowed_by_playbook", lambda: True) + monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={ + "regime_200d": "bull", "drop_from_ma20": 1.0, "max_trades": 5, "position_multiplier": 1.0})) + monkeypatch.setattr(es, "count_swing_positions", AsyncMock(return_value=0)) + monkeypatch.setattr(es.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) + monkeypatch.setattr(ad, "get_current_adaptive_params", + lambda: SimpleNamespace(fallback_used=False, trading_mode="normal")) + monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0)) + monkeypatch.setattr(es.asyncio, "sleep", AsyncMock(return_value=None)) + api = _api(legacy_raises=True) + monkeypatch.setattr(kia, "korea_investment_api", api) + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=_bars(100))) + monkeypatch.setattr(mr, "check_momentum_breakout_signal_v2", lambda **kw: None) + seen = {} + + def _pullback_spy(**kw): + seen["n"] = len(kw["closes"]) + return {"signal": "BUY", "strategy": "PULLBACK_BUY", "confidence": 0.6, "description": "t"} + + monkeypatch.setattr(mr, "check_pullback_buy_signal_v2", _pullback_spy) + delegate = AsyncMock(side_effect=_Reached("delegated")) + monkeypatch.setattr(es, "execute_auto_trade_background", delegate) + stock = SimpleNamespace(stock_code="005930", stock_name="T", current_price=70000, score=5) + return SimpleNamespace(es=es, api=api, delegate=delegate, seen=seen, stock=stock) + + +@pytest.mark.asyncio +async def test_dual_scan_feeds_pullback_at_least_min_bars(dual_env): + """[결함 반전] 이전엔 get_daily_chart 30봉만 전달돼 눌림목이 매번 길이 검사에서 탈락했다.""" + with pytest.raises(_Reached): + await dual_env.es.scan_dual_strategy_signals(_DualDB([dual_env.stock])) + assert dual_env.seen["n"] >= dual_env.es.DUAL_SCAN_MIN_BARS + dual_env.api.get_daily_chart.assert_not_called() + rec = dual_env.delegate.call_args.args[0] + assert rec["recommendations_by_rule"][0]["stocks"][0]["strategy_name"] == "PULLBACK_BUY" + + +@pytest.mark.asyncio +async def test_dual_scan_warns_when_bars_short(monkeypatch, dual_env): + """60봉 미달은 warning 으로 가시화(조용한 미발화 금지).""" + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=None)) + dual_env.api.get_daily_chart = AsyncMock(return_value={"output2": list(reversed(_bars(30)))}) + monkeypatch.setattr(mr, "check_pullback_buy_signal_v2", lambda **kw: None) + calls = [] + monkeypatch.setattr(dual_env.es, "warn_throttled", + lambda lg, key, msg, *a, **k: calls.append((key, msg))) + result = await dual_env.es.scan_dual_strategy_signals(_DualDB([dual_env.stock])) + assert result == [] + short = [m for k, m in calls if k.startswith("entry_service:short_bars:DualStrategy")] + assert short and f"{dual_env.es.DUAL_SCAN_MIN_BARS}봉 미달" in short[0] + assert "005930:30봉" in short[0] + + +# ═══════════════════════════════════════════════════════════════════════════ +# L — 가격 필터 경계 통일 +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.parametrize("price,blocked", [ + (lambda: settings.MIN_PRICE_KRW, True), # 정확히 경계 → 거부('초과'만 허용) + (lambda: settings.MIN_PRICE_KRW - 1, True), + (lambda: settings.MIN_PRICE_KRW + 1, False), + (lambda: None, True), + (lambda: "abc", True), + (lambda: float("nan"), True), + (lambda: float("inf"), True), +]) +def test_below_min_price_krw_v2_boundary(price, blocked): + from app.services.swing.entry_service import _below_min_price_krw_v2 + assert _below_min_price_krw_v2(price()) is blocked + + +@pytest.mark.asyncio +async def test_dual_scan_rejects_exact_min_price_before_chart(monkeypatch, dual_env): + """[결함 반전] 듀얼 후보 단계가 `<` 라 정확히 MIN_PRICE_KRW 종목이 통과해 차트 조회·평가 슬롯을 썼다.""" + stock = SimpleNamespace(stock_code="000001", stock_name="P", + current_price=settings.MIN_PRICE_KRW, score=5) + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(side_effect=_Reached("chart fetched"))) + result = await dual_env.es.scan_dual_strategy_signals(_DualDB([stock])) + assert result == [] + dual_env.delegate.assert_not_called() + + +@pytest.mark.asyncio +async def test_dual_scan_rejects_when_chart_close_at_min_price(monkeypatch, dual_env): + """후보 가격은 통과해도 차트 현재 종가가 MIN_PRICE_KRW 이하이면 진입 위임하지 않는다.""" + bars = _bars(100, start_close=float(settings.MIN_PRICE_KRW) - 99 * 10) + assert float(bars[-1]["stck_clpr"]) == float(settings.MIN_PRICE_KRW) + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=bars)) + result = await dual_env.es.scan_dual_strategy_signals(_DualDB([dual_env.stock])) + assert result == [] + dual_env.delegate.assert_not_called() diff --git a/backend/tests/test_swing_exit_cooldown_r8_20261010.py b/backend/tests/test_swing_exit_cooldown_r8_20261010.py new file mode 100644 index 0000000..2f26c21 --- /dev/null +++ b/backend/tests/test_swing_exit_cooldown_r8_20261010.py @@ -0,0 +1,196 @@ +"""스윙 청산 하드닝 R8 (2026-10-10) — 청산 쿨다운이 손절(전량형)을 막던 결함. + +[MEDIUM] EXIT_COOLDOWN_SEC 쿨다운이 신호 종류를 보지 않아, KIS 주문가능 상한에 걸려 일부만 팔린 +손절의 잔량·1차 익절 직후 급락 손절이 최소 EXIT_COOLDOWN_SEC 동안 보류됐다(§8.1-11 위반). +이제 국내 전량형(sell_pct>=1.0) 신호는 쿨다운을 우회하고, 분할 신호와 해외는 기존대로 쿨다운. +과다매도는 매도 직전 KIS 실보유 상한(C-1)이 막는다 — 테스트로 함께 고정한다. + +규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order), 실패 주입은 예외 발생형, +쿨다운 길이는 모듈 상수(EXIT_COOLDOWN_SEC) 참조. +""" +import time +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import app.services.mean_reversion_strategy as mr +from app.services.mean_reversion_strategy import ExitSignal + +OK = {"rt_cd": "0", "output": {"ODNO": "9"}} + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch): + import app.services.swing.exit_service as es + import app.services.telegram_notifier as tn + for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr", + "_presell_check_failures", "_no_price_skips", "_recent_exits", + "_swing_exit_error_counts", "_swing_sell_reject_counts", "_swing_sell_reject_last"): + monkeypatch.setattr(es, name, {}) + monkeypatch.setattr(es, "_exit_run_state_v2", { + "seq": 0, "ok": True, "error": None, "holdings": 0, "errored": 0, "errors": [], + }) + # Redis 실접속 차단: 복원은 완료 상태로 두고, 불명 매도 영속화는 호출되면 실패(예외 발생형) + monkeypatch.setattr(es, "_unknown_sells_restore_state", "ok") + monkeypatch.setattr(es, "_persist_unknown_sell_v2", + AsyncMock(side_effect=AssertionError("불명 매도 경로 미예상"))) + from app.core import log_throttle + log_throttle.reset_for_tests() + import app.services.adaptive_strategy as ad + monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True)) + monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None) + import app.services.trade_executor as te + monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) + monkeypatch.setattr(tn.telegram_notifier, "send_message", + AsyncMock(side_effect=AssertionError("텔레그램 실전송 금지"))) + return es + + +def _env(monkeypatch, *, signal, price=8000, qty=10, kis=(10, 10), remaining_pct=1.0): + """평균 10000, 현재가 price, 장부 스윙 qty. check_exit_signal 은 signal 반환. + kis = (보유, 주문가능) 를 매도 직전 KIS 실보유로 돌려준다(리스트면 호출마다 순서대로).""" + import app.services.swing.exit_service as es + import app.services.trade_executor as te + import app.services.db_worker as dbw + import app.services.portfolio_cache_bridge as pcb + from app.core.timeutil import now_kst + from app.services.daytrading.position_manager import day_trading_state + + holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=qty, average_price=10000, + current_price=price, market="KRX", updated_at=now_kst()) + state = {"holding": holding} + monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(side_effect=lambda *a, **k: [state["holding"]])) + monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"})) + at = SimpleNamespace(strategy_name="swing", remaining_pct=remaining_pct, highest_since_entry=None, + created_at=now_kst() - timedelta(days=2), entry_reason=None, entry_price=10000) + monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at)) + api = MagicMock() + bars = [{"stck_clpr": str(price + (i % 3) * 50), "stck_hgpr": str(price + 200 + (i % 3) * 50), + "stck_lwpr": str(price - 200 + (i % 3) * 50)} for i in range(30)] + api.get_daily_chart = AsyncMock(return_value={"output2": bars}) + api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}}) + api.get_balance = AsyncMock(side_effect=AssertionError("잔고 조회는 _presell_kis_holding_v2 목으로")) + monkeypatch.setattr(es, "korea_investment_api", api) + sig = {"s": signal} + monkeypatch.setattr(es, "check_exit_signal", lambda **kw: sig["s"]) + monkeypatch.setattr(es, "_check_momentum_exit", lambda **kw: sig["s"], raising=False) + kis_seq = list(kis) if isinstance(kis, list) else None + kis_state = {"v": kis} + + async def _presell(code): + if kis_seq is not None: + return kis_seq.pop(0) + return kis_state["v"] + monkeypatch.setattr(es, "_presell_kis_holding_v2", _presell) + monkeypatch.setattr(day_trading_state, "positions", {}) + monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock()) + sent = [] + + async def _spo(**kw): + sent.append((kw["stock_code"], kw["quantity"])) + return OK + monkeypatch.setattr(te, "safe_place_order", _spo) + monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock()) + monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock()) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", AsyncMock()) + monkeypatch.setattr(dbw, "enqueue_db_write", AsyncMock()) + monkeypatch.setattr(pcb, "reflect_sell", AsyncMock()) + monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", MagicMock()) + monkeypatch.setattr(es, "_prior_position_realized_net_v2", AsyncMock(return_value=None)) + monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", AsyncMock()) + monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock()) + return SimpleNamespace(es=es, sent=sent, sig=sig, state=state, kis_state=kis_state) + + +def _mark_recent_exit(es, ago_sec=10): + assert ago_sec < es.EXIT_COOLDOWN_SEC + es._recent_exits["005930"] = time.time() - ago_sec + + +# ── 헬퍼 단위 ──────────────────────────────────────────────────────────────── + +def test_helper_partial_domestic_applies(_reset): + es = _reset + assert es._exit_cooldown_applies_v2(ExitSignal("SELL_PARTIAL", "r", 0.5), True) is True + + +def test_helper_full_domestic_bypasses(_reset): + es = _reset + assert es._exit_cooldown_applies_v2(ExitSignal("SELL_ALL", "손절", 1.0), True) is False + + +def test_helper_overseas_always_applies(_reset): + """해외는 매도 직전 실보유 상한이 없으므로 전량형도 쿨다운 유지(과다매도 방지).""" + es = _reset + assert es._exit_cooldown_applies_v2(ExitSignal("SELL_ALL", "손절", 1.0), False) is True + + +def test_helper_unparseable_sell_pct_treated_as_full(_reset): + es = _reset + assert es._exit_cooldown_applies_v2(SimpleNamespace(sell_pct=None, reason="x"), True) is False + assert es._exit_cooldown_applies_v2(SimpleNamespace(sell_pct=float("nan"), reason="x"), True) is False + + +# ── 통합: 청산 루프 ─────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_stop_loss_not_blocked_by_cooldown(monkeypatch, _reset): + """[재현 반전] 이전: 직전 청산 10초 전이면 손절(SELL_ALL)도 continue → 주문 0건.""" + env = _env(monkeypatch, signal=ExitSignal("SELL_ALL", "손절 테스트", 1.0)) + _mark_recent_exit(env.es) + await env.es.check_and_execute_exit_v2(MagicMock()) + assert env.sent == [("005930", 10)] + + +@pytest.mark.asyncio +async def test_partial_signal_still_blocked_by_cooldown(monkeypatch, _reset): + env = _env(monkeypatch, signal=ExitSignal("SELL_PARTIAL", "1차 익절", 0.5), price=11000) + _mark_recent_exit(env.es) + await env.es.check_and_execute_exit_v2(MagicMock()) + assert env.sent == [] + + +@pytest.mark.asyncio +async def test_partial_signal_after_cooldown_expires(monkeypatch, _reset): + env = _env(monkeypatch, signal=ExitSignal("SELL_PARTIAL", "1차 익절", 0.5), price=11000) + env.es._recent_exits["005930"] = time.time() - env.es.EXIT_COOLDOWN_SEC - 1 + await env.es.check_and_execute_exit_v2(MagicMock()) + assert env.sent == [("005930", 5)] + + +@pytest.mark.asyncio +async def test_capped_stop_loss_remainder_sold_next_cycle_within_cooldown(monkeypatch, _reset): + """KIS 주문가능 상한(4주)에 걸려 일부만 팔린 손절 → 다음 주기(쿨다운 안)에 잔량 손절 진행. + 잔량 수량은 KIS 실보유 상한으로만 결정(장부 10주가 낡아도 6주만) — 과다매도 없음.""" + env = _env(monkeypatch, signal=ExitSignal("SELL_ALL", "손절 테스트", 1.0), + kis=[(10, 4), (6, 6)]) + await env.es.check_and_execute_exit_v2(MagicMock()) + assert env.sent == [("005930", 4)] + assert "005930" in env.es._recent_exits # 쿨다운 기록됨 + # 장부(Portfolio)는 아직 sync 전이라 10주 그대로 — KIS 상한이 6주로 막아야 한다 + await env.es.check_and_execute_exit_v2(MagicMock()) + assert env.sent == [("005930", 4), ("005930", 6)] + + +@pytest.mark.asyncio +async def test_stop_after_partial_take_profit_not_blocked(monkeypatch, _reset): + """1차 익절 직후 급락 → 같은 쿨다운 창 안의 손절이 즉시 나간다(수량은 KIS 실보유 상한).""" + env = _env(monkeypatch, signal=ExitSignal("SELL_PARTIAL", "1차 익절", 0.5), price=11000, + kis=[(10, 10), (5, 5)]) + await env.es.check_and_execute_exit_v2(MagicMock()) + assert env.sent == [("005930", 5)] + env.sig["s"] = ExitSignal("SELL_ALL", "손절 테스트", 1.0) + await env.es.check_and_execute_exit_v2(MagicMock()) + assert env.sent[-1] == ("005930", 5) + assert len(env.sent) == 2 + + +@pytest.mark.asyncio +async def test_bypass_respects_kis_zero_cap(monkeypatch, _reset): + """쿨다운 우회여도 KIS 매도 가능 0 이면 주문하지 않는다(직전 시장가 미체결 등).""" + env = _env(monkeypatch, signal=ExitSignal("SELL_ALL", "손절 테스트", 1.0), kis=(10, 0)) + _mark_recent_exit(env.es) + await env.es.check_and_execute_exit_v2(MagicMock()) + assert env.sent == [] diff --git a/backend/tests/test_swing_exit_hardening_r1_20261009.py b/backend/tests/test_swing_exit_hardening_r1_20261009.py new file mode 100644 index 0000000..0476ddc --- /dev/null +++ b/backend/tests/test_swing_exit_hardening_r1_20261009.py @@ -0,0 +1,348 @@ +"""스윙 청산 하드닝 R1 (2026-10-09) — swing/exit_service 회귀 테스트. + +#1/#3 실시세 정상 + 일봉/지표 조회 실패 시 폴백 손절이 ATR=None → 일반 손절(-7%)로 고변동 종목을 + 오손절하던 것 → 대체가 경로와 같은 규칙(캐시 ATR → 느슨한 쪽). +#2 'synced'/'외부 유입' 시딩 행의 시간손절 진입일 보정 + TradeHistory 기반 전량청산 하한. +#4 전량 청산 loss_tracker 승패 = 포지션 누적 순손익(이전 분할 레그 + 이번 레그). +#6 스윙 SELL TradeHistory 의 commission·net_profit_loss(비용 차감). + +규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order), 기대값은 settings.* 참조. +""" +import time +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy.dialects import postgresql + +import app.services.mean_reversion_strategy as mr +from app.core.config import settings +from app.services.mean_reversion_strategy import ExitSignal + +OK = {"rt_cd": "0", "output": {"ODNO": "9"}} + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch): + import app.services.swing.exit_service as es + for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr", + "_presell_check_failures", "_no_price_skips", "_recent_exits"): + monkeypatch.setattr(es, name, {}) + from app.core import log_throttle + log_throttle.reset_for_tests() + import app.services.adaptive_strategy as ad + monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True)) + monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None) + import app.services.trade_executor as te + monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) + + +class _RowsDB: + """execute 마다 미리 준비한 행 목록을 .all()/.scalar() 로 돌려주고 문장을 기록한다.""" + + def __init__(self, *row_sets): + self.row_sets = list(row_sets) + self.statements = [] + + async def execute(self, stmt): + self.statements.append(stmt) + rows = self.row_sets.pop(0) if self.row_sets else [] + return SimpleNamespace(all=lambda: list(rows), scalar=lambda: (rows[0] if rows else None)) + + +class _BoomDB: + async def execute(self, stmt): + raise RuntimeError("db down") + + +def _sql(stmt) -> str: + return str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) + + +def _env(monkeypatch, *, avg=10000, price, high_vol=True, chart=None, signal=None, + order=None, at_kwargs=None): + """chart: None=정상 30봉, Exception=조회 실패, list=그 봉들.""" + import app.services.swing.exit_service as es + import app.services.trade_executor as te + import app.services.db_worker as dbw + import app.services.portfolio_cache_bridge as pcb + from app.core.timeutil import now_kst + from app.services.daytrading.position_manager import day_trading_state + + holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=10, average_price=avg, + current_price=price, market="KRX", updated_at=now_kst()) + monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=[holding])) + monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"})) + at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None, + created_at=now_kst() - timedelta(days=2), entry_reason=None, + entry_price=avg) + for k, v in (at_kwargs or {}).items(): + setattr(at, k, v) + monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at)) + api = MagicMock() + rng = 250 if high_vol else 40 # 고변동: ATR≈500(>3%) + bars = [{"stck_clpr": str(price + 100 + (i % 3) * 50), "stck_hgpr": str(price + 100 + rng + (i % 3) * 50), + "stck_lwpr": str(price + 100 - rng + (i % 3) * 50)} for i in range(30)] + if isinstance(chart, Exception): + api.get_daily_chart = AsyncMock(side_effect=chart) + else: + api.get_daily_chart = AsyncMock(return_value={"output2": bars if chart is None else chart}) + api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}}) + api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ + {"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]}) + monkeypatch.setattr(es, "korea_investment_api", api) + if signal is not None: + monkeypatch.setattr(es, "check_exit_signal", lambda **kw: signal) + monkeypatch.setattr(day_trading_state, "positions", {}) + monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock()) + sent = [] + + async def _spo(**kw): + sent.append(kw["quantity"]) + return order if order is not None else {"rt_cd": "1", "msg_cd": "X", "msg1": "stop here"} + monkeypatch.setattr(te, "safe_place_order", _spo) + history = [] + monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock(side_effect=lambda p: history.append(p))) + monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock()) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", AsyncMock()) + monkeypatch.setattr(pcb, "reflect_sell", AsyncMock()) + monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", MagicMock()) + # [R1-b] 보유 집계 SELECT(휴면·페이지 감시)는 _RowsDB 의 순차 결과를 소비하므로 제외 — + # 집계 자체는 test_swing_exit_hardening_r1b_20261009 에서 검증한다. + monkeypatch.setattr(es, "_portfolio_census_v2", AsyncMock(return_value=None)) + loss = AsyncMock() + monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", loss) + monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock()) + return SimpleNamespace(es=es, sent=sent, history=history, loss=loss, at=at) + + +async def _cycle(es, db=None): + es._recent_exits.clear() + await es.check_and_execute_exit_v2(db if db is not None else MagicMock()) + + +# ═══════════════════════════════════════════════════════════════════════════ +# #1 / #3 — 실시세 정상 + 일봉 조회 실패 폴백 손절 +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_nonstale_chart_failure_uses_cached_high_vol_atr(monkeypatch): + """[재현 반전] 고변동 -8%: 정상 주기 보유 → 차트 실패 주기에도 보유(이전: -7% 시장가 오손절).""" + env = _env(monkeypatch, price=9200) + await _cycle(env.es) + assert env.sent == [] and "005930" in env.es._last_atr + env2 = _env(monkeypatch, price=9200, chart=RuntimeError("EGW00201")) + await _cycle(env2.es) + assert env2.sent == [] + + +@pytest.mark.asyncio +async def test_nonstale_short_chart_without_cache_uses_loosest(monkeypatch): + loosest = -max(abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL)) + tight = -min(abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL)) + between = int(10000 * (1 + (tight + loosest) / 2 / 100)) + env = _env(monkeypatch, price=between, chart=[]) # 20봉 미만 + await _cycle(env.es) + assert env.sent == [] + beyond = int(10000 * (1 + (loosest - 1) / 100)) + env2 = _env(monkeypatch, price=beyond, chart=RuntimeError("down")) + await _cycle(env2.es) + assert env2.sent == [10] # 손절 자체는 계속 동작(try 밖 판정) + + +@pytest.mark.asyncio +async def test_nonstale_chart_failure_cached_low_vol_keeps_normal_stop(monkeypatch): + floor = -abs(settings.STOP_LOSS_PERCENT) + below = int(10000 * (1 + (floor - 1) / 100)) + env = _env(monkeypatch, price=below, chart=RuntimeError("down")) + from app.core.timeutil import now_kst + env.es._last_atr["005930"] = (below * 0.01, time.monotonic(), now_kst()) # ATR 1% 저변동 + await _cycle(env.es) + assert env.sent == [10] + + +def test_nonstale_helper_prefers_current_atr_and_signs_negative(monkeypatch): + import app.services.swing.exit_service as es + # 이번 ATR 이 있으면 기존 _fallback_stop_loss_pct_v2 경로(저변동 → 일반 손절) + assert es._nonstale_fallback_stop_pct_v2("005930", 10.0, 10000.0, None) == -abs(settings.STOP_LOSS_PERCENT) + # 없으면 대체가 규칙(캐시 없음 → 느슨한 쪽) + assert es._nonstale_fallback_stop_pct_v2("005930", None, 10000.0, None) == -max( + abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL)) + assert es._nonstale_fallback_stop_pct_v2("005930", "bad", 10000.0, None) < 0 + + +# ═══════════════════════════════════════════════════════════════════════════ +# #2 — 시딩 행 시간손절 진입일 +# ═══════════════════════════════════════════════════════════════════════════ + +_EXT = "외부 유입 포지션 — 동기화 시 자동 등록(진입가=평균단가)" + + +@pytest.mark.asyncio +async def test_v3_synced_row_uses_original_buy_despite_race_closed_row(): + """181710 재현: BUY 10 (9/23) → 분할 SELL 5 (10/6 09:12) → 레이스로 swing 행 closed → + synced 재시딩(10/6 09:18). 이전: created_at(10/6). 이제: 9/23.""" + import app.services.swing.exit_service as es + created = datetime(2026, 10, 6, 9, 18) + buy = datetime(2026, 9, 23, 12, 53) + db = _RowsDB([("BUY", 10, 10, buy), ("SELL", 5, 5, datetime(2026, 10, 6, 9, 12))]) + at = SimpleNamespace(created_at=created, entry_reason=_EXT, strategy_name="synced") + basis = await es._resolve_time_stop_entry_v3(db, "181710", at) + assert basis.created_at == buy + sql = _sql(db.statements[0]) + assert "trade_history.status = 'COMPLETED'" in sql + assert "trade_history.created_at <= '2026-10-06 09:18:00'" in sql + assert "day_trade" in sql # 비-DT 정본 절 + + +@pytest.mark.asyncio +async def test_v3_recognizes_strategy_name_or_either_prefix(): + import app.services.swing.exit_service as es + buy = datetime(2026, 9, 1, 10, 0) + for kw in ({"strategy_name": "synced", "entry_reason": None}, + {"strategy_name": None, "entry_reason": _EXT}, + {"strategy_name": "swing", "entry_reason": "동기화 유입 포지션 (부분매도 시 자동 생성)"}): + at = SimpleNamespace(created_at=datetime(2026, 10, 6), **kw) + basis = await es._resolve_time_stop_entry_v3(_RowsDB([("BUY", 3, 3, buy)]), "X", at) + assert basis.created_at == buy + + +@pytest.mark.asyncio +async def test_v3_lower_bound_is_last_flat_sell(): + """이전 포지션이 TradeHistory 상 전량 청산된 뒤의 BUY 만 진입으로 본다.""" + import app.services.swing.exit_service as es + rows = [("BUY", 5, 5, datetime(2026, 7, 1)), ("SELL", 5, 5, datetime(2026, 7, 20)), + ("BUY", 4, 4, datetime(2026, 9, 2))] + at = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason=_EXT, strategy_name="synced") + basis = await es._resolve_time_stop_entry_v3(_RowsDB(rows), "X", at) + assert basis.created_at == datetime(2026, 9, 2) + + +@pytest.mark.asyncio +async def test_v3_fully_sold_history_keeps_created_at(): + import app.services.swing.exit_service as es + rows = [("BUY", 5, 5, datetime(2026, 7, 1)), ("SELL", 5, 5, datetime(2026, 7, 20))] + at = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason=_EXT, strategy_name="synced") + assert await es._resolve_time_stop_entry_v3(_RowsDB(rows), "X", at) is at + + +@pytest.mark.asyncio +async def test_v3_non_seeded_row_does_not_query_and_failure_keeps_created_at(): + import app.services.swing.exit_service as es + at = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason="기술: RSI", strategy_name="swing") + assert await es._resolve_time_stop_entry_v3(_BoomDB(), "X", at) is at # DB 미조회 + seeded = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason=_EXT, strategy_name="synced") + assert await es._resolve_time_stop_entry_v3(_BoomDB(), "X", seeded) is seeded + + +@pytest.mark.asyncio +async def test_v3_wired_into_time_stop(monkeypatch): + """synced 행 + 원 BUY 가 SWING_TIME_STOP_DAYS 거래일 이전 → 시간손절 발동(이전: 재시딩일 기준 미발동).""" + from app.core.timeutil import now_kst + monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", True, raising=False) + env = _env(monkeypatch, price=10050, high_vol=False, signal=None, order=OK, + at_kwargs={"strategy_name": "synced", "entry_reason": _EXT, + "created_at": now_kst().replace(tzinfo=None) - timedelta(days=1)}) + monkeypatch.setattr(env.es, "check_exit_signal", lambda **kw: None) + old_buy = now_kst().replace(tzinfo=None) - timedelta(days=int(settings.SWING_TIME_STOP_DAYS) * 2) + db = _RowsDB([("BUY", 10, 10, old_buy)], []) # ① 시간손절 진입일 ② 누적 실현손익(없음) + await _cycle(env.es, db) + assert env.sent == [10] + assert env.history[0]["exit_reason"].startswith("시간손절") + + +# ═══════════════════════════════════════════════════════════════════════════ +# #4 — 포지션 단위 순손익 승패 / #6 — commission·net 기록 +# ═══════════════════════════════════════════════════════════════════════════ + +def _cost(entry, exit_, q): + from app.services.trade_helpers import calculate_trade_costs + return calculate_trade_costs(entry, exit_, q, True, "KOSPI")["total_cost"] + + +def test_position_is_loss_v2(): + import app.services.swing.exit_service as es + assert es._position_is_loss_v2(8000.0, -410.0) is False + assert es._position_is_loss_v2(None, -410.0) is True + assert es._position_is_loss_v2(-1.0, 0.5) is True + + +@pytest.mark.asyncio +async def test_prior_realized_net_converts_gross_rows_and_uses_net_rows(): + import app.services.swing.exit_service as es + # 행: (net_profit_loss, commission, total_amount, executed_quantity, order_quantity) + # ① 과거 gross 기록(commission NULL): 진입 10,000 → 10,500 × 10 = gross 5,000 + # ② 비용 기록 행: net 그대로 + rows = [(5000.0, None, 105000.0, 10, 10), (1234.0, 50.0, 99999.0, 5, 5), (None, None, 1, 1, 1)] + at = SimpleNamespace(created_at=datetime(2026, 10, 1), entry_reason=None, strategy_name="swing") + db = _RowsDB(rows) + got = await es._prior_position_realized_net_v2(db, "005930", at, "KRX") + assert got == pytest.approx(5000.0 - _cost(10000.0, 10500.0, 10) + 1234.0) + sql = _sql(db.statements[0]) + assert "trade_history.order_type = 'SELL'" in sql and "trade_history.created_at >= '2026-10-01" in sql + + +@pytest.mark.asyncio +async def test_prior_realized_net_failure_returns_none(): + import app.services.swing.exit_service as es + at = SimpleNamespace(created_at=datetime(2026, 10, 1), entry_reason=None, strategy_name="swing") + assert await es._prior_position_realized_net_v2(_BoomDB(), "005930", at, "KRX") is None + assert await es._prior_position_realized_net_v2(_RowsDB(), "005930", None, "KRX") is None + + +@pytest.mark.asyncio +async def test_full_exit_after_profitable_partials_is_not_loss(monkeypatch): + """449450 재현: 1차 익절 2회(+3,900/+4,125) 후 잔량 트레일링 소폭 손실 → 포지션은 이익 → 패 아님.""" + env = _env(monkeypatch, price=9990, high_vol=False, order=OK, + signal=ExitSignal("SELL_ALL", "전량청산: 트레일링", 1.0)) + monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", False, raising=False) + db = _RowsDB([(3900.0, None, 0, 0, 0), (4125.0, None, 0, 0, 0)]) + await _cycle(env.es, db) + assert env.sent == [10] + env.loss.assert_awaited_once() + assert env.loss.await_args.kwargs["is_loss"] is False + # #6: commission·순손익 기록 + h = env.history[0] + assert h["commission"] == _cost(10000.0, 9990.0, 10) + assert h["net_profit_loss"] == pytest.approx((9990 - 10000) * 10 - _cost(10000.0, 9990.0, 10)) + + +@pytest.mark.asyncio +async def test_full_exit_tiny_gross_profit_within_costs_is_loss(monkeypatch): + """비용 이내 미세 이익(+0.05%) 단일 레그 → 순손익 음수 → 패(§5.1).""" + env = _env(monkeypatch, price=10005, high_vol=False, order=OK, + signal=ExitSignal("SELL_ALL", "BB상단", 1.0)) + monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", False, raising=False) + await _cycle(env.es, _RowsDB([])) + assert env.loss.await_args.kwargs["is_loss"] is True + + +@pytest.mark.asyncio +async def test_full_exit_prior_lookup_failure_still_sells_and_records(monkeypatch): + env = _env(monkeypatch, price=9000, high_vol=False, order=OK, + signal=ExitSignal("SELL_ALL", "손절", 1.0)) + monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", False, raising=False) + await _cycle(env.es, _BoomDB()) + assert env.sent == [10] + assert env.loss.await_args.kwargs["is_loss"] is True + + +@pytest.mark.asyncio +async def test_unknown_reconcile_uses_prior_realized_net(monkeypatch): + import app.services.swing.exit_service as es + import app.services.portfolio_cache_bridge as pcb + monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock()) + monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock()) + monkeypatch.setattr(pcb, "reflect_sell", AsyncMock()) + loss = AsyncMock() + monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", loss) + monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock()) + es._pending_unknown_sells["005930"] = { + "qty": 10, "pre_hldg": 10, "since": time.time(), "price": 9990.0, "avg_price": 10000.0, + "swing_qty": 10, "sell_pct": 1.0, "remaining_pct": 0.5, "original_qty": 20, + "strategy": "swing", "filter_not_dt": False, "had_active_trade": True, + "stock_name": "T", "reason": "트레일링", "market": "KRX", "prior_realized_net": 8000.0, + } + assert await es._reconcile_unknown_sell_v2("005930", 0, 0) is True + assert loss.await_args.kwargs["is_loss"] is False diff --git a/backend/tests/test_swing_exit_hardening_r1b_20261009.py b/backend/tests/test_swing_exit_hardening_r1b_20261009.py new file mode 100644 index 0000000..6263245 --- /dev/null +++ b/backend/tests/test_swing_exit_hardening_r1b_20261009.py @@ -0,0 +1,243 @@ +"""스윙 청산 하드닝 R1-b (2026-10-09) — swing/exit_service 회귀 테스트. + +#2 [MEDIUM] 스윙 매도 TradeHistory 의 executed_price 가 신호가였다 → NULL + 체결 확인 예약(공통 종단 목). +#3 [LOW] '1차 익절(50%)' 사유가 소량 전량매도에도 그대로 기록 → 실효 사유(_effective_exit_reason_v2). +#5 [MEDIUM] 판정 대상 밖 보유(strategy_type NULL·미상)로 청산 루프가 무경보 휴면 → 연속 주기 경보 + /health. +#6 [MEDIUM] 잔고 첫 페이지 포화 근접(손절 보류 절벽) → 경보 + /health. + +규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order), 실패 주입은 예외 발생형, +임계는 settings/모듈 기본값 참조(하드코딩 금지). +""" +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import app.services.mean_reversion_strategy as mr +from app.core.config import settings +from app.services.mean_reversion_strategy import ExitSignal + +OK = {"rt_cd": "0", "output": {"ODNO": "77"}} + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch): + import app.services.swing.exit_service as es + import app.services.telegram_notifier as tn + import app.services.swing.scheduler as sch + for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr", + "_presell_check_failures", "_no_price_skips", "_recent_exits", + "_swing_exit_error_counts", "_swing_sell_reject_counts", "_swing_sell_reject_last", + "_census_state_v2"): + monkeypatch.setattr(es, name, {}) + monkeypatch.setattr(es, "_unowned_streak", 0) + monkeypatch.setattr(es, "_empty_holdings_streak", 0) + monkeypatch.setattr(es, "_exit_run_state_v2", { + "seq": 0, "ok": True, "error": None, "holdings": 0, "errored": 0, "errors": [], + }) + from app.core import log_throttle + log_throttle.reset_for_tests() + import app.services.adaptive_strategy as ad + monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True)) + monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None) + import app.services.trade_executor as te + monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) + monkeypatch.setattr(tn.telegram_notifier, "send_message", + AsyncMock(side_effect=AssertionError("텔레그램 실전송 금지"))) + alerts = [] + monkeypatch.setattr(sch, "_notify_throttled_v2", + lambda key, category, message, repeat_sec=None: alerts.append((key, message)) or True) + return SimpleNamespace(es=es, alerts=alerts) + + +def _db(rows=None, exc=None): + db = MagicMock() + if exc is not None: + db.execute = AsyncMock(side_effect=exc) + else: + res = MagicMock() + res.all = MagicMock(return_value=list(rows or [])) + db.execute = AsyncMock(return_value=res) + return db + + +def _env(monkeypatch, *, codes=("005930",), qty=10, price=8000, signal=None, + hldg=None, sellable=None): + import app.services.swing.exit_service as es + import app.services.trade_executor as te + import app.services.db_worker as dbw + import app.services.portfolio_cache_bridge as pcb + from app.core.timeutil import now_kst + from app.services.daytrading.position_manager import day_trading_state + + holdings = [SimpleNamespace(stock_code=c, stock_name="T", quantity=qty, average_price=10000, + current_price=price, market="KRX", updated_at=now_kst()) for c in codes] + monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=holdings)) + monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"})) + at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None, + created_at=now_kst() - timedelta(days=2), entry_reason=None, entry_price=10000) + monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at)) + api = MagicMock() + bars = [{"stck_clpr": str(price + (i % 3) * 50), "stck_hgpr": str(price + 200 + (i % 3) * 50), + "stck_lwpr": str(price - 200 + (i % 3) * 50)} for i in range(30)] + api.get_daily_chart = AsyncMock(return_value={"output2": bars}) + api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}}) + _h = qty if hldg is None else hldg + _s = _h if sellable is None else sellable + api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ + {"pdno": c, "hldg_qty": str(_h), "ord_psbl_qty": str(_s)} for c in codes]}) + monkeypatch.setattr(es, "korea_investment_api", api) + sig = signal or ExitSignal(action="SELL_ALL", reason="손절 테스트", sell_pct=1.0) + monkeypatch.setattr(es, "check_exit_signal", lambda **kw: sig) + monkeypatch.setattr(day_trading_state, "positions", {}) + monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock()) + sent = [] + + async def _spo(**kw): + sent.append((kw["stock_code"], kw["quantity"])) + return OK + monkeypatch.setattr(te, "safe_place_order", _spo) + th = AsyncMock() + monkeypatch.setattr(es, "enqueue_trade_history", th) + monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock()) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", AsyncMock()) + monkeypatch.setattr(dbw, "enqueue_db_write", AsyncMock()) + monkeypatch.setattr(pcb, "reflect_sell", AsyncMock()) + fill = MagicMock() + monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", fill) + monkeypatch.setattr(es, "_prior_position_realized_net_v2", AsyncMock(return_value=None)) + monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", AsyncMock()) + monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock()) + return SimpleNamespace(es=es, sent=sent, th=th, fill=fill) + + +async def _cycle(es, db=None): + es._recent_exits.clear() + return await es.check_and_execute_exit_v2(db if db is not None else MagicMock()) + + +# ═══ #2 — 체결가는 신호가로 저장하지 않는다 ═════════════════════════════════ + +@pytest.mark.asyncio +async def test_stop_loss_sell_records_null_executed_price_and_schedules_fill_check(monkeypatch): + env = _env(monkeypatch, price=8000) + await _cycle(env.es) + assert env.sent == [("005930", 10)] + payload = env.th.await_args.args[0] + assert payload["order_type"] == "SELL" + assert payload["executed_price"] is None # 이전(main@10-07): 신호가 8000 + assert payload["order_price"] == 8000 + env.fill.assert_called_once() + kw = env.fill.call_args.kwargs + assert kw["order_id"] == "77" and kw["sell_qty"] == 10 and kw["ref_price"] == 8000.0 + + +# ═══ #3 — 실효 청산 사유 ═════════════════════════════════════════════════════ + +def test_effective_reason_helper_cases(): + from app.services.swing.exit_service import _effective_exit_reason_v2 as f + from app.services.trade_analyzer import _parse_exit_type + r = "1차 익절(50%): BB중앙(100)상회" + small = f(r, 0.5, 2, 2, 2) + assert small.startswith("소량 전량매도(분할 신호 50% → 실제 2/2주 전량") and small.endswith(r) + assert _parse_exit_type(small) == "take_profit" + assert f(r, 0.5, 5, 10, 10) == r # 정상 분할 — 원 사유 그대로 + capped = f("손절 (close=1 <= stop=2)", 1.0, 4, 10, 10) + assert capped.startswith("KIS 상한 부분매도(전량 신호 → 실제 4/10주)") + assert _parse_exit_type(capped) == "stop_loss" + assert f("손절", 1.0, 10, 10, 10) == "손절" + assert f("x", "bad", 1, 1, 1) == "x" # 가공 실패 → 원 사유(매도는 막지 않음) + + +@pytest.mark.asyncio +async def test_small_position_partial_signal_records_full_sell_reason(monkeypatch): + """[재현 반전] id330 KT&G 2주 전량 — 이전: exit_reason '1차 익절(50%)…' 그대로.""" + sig = ExitSignal(action="SELL_PARTIAL", reason="1차 익절(50%): BB중앙(9000)상회", sell_pct=0.5) + env = _env(monkeypatch, qty=2, price=12000, signal=sig) + await _cycle(env.es) + assert env.sent == [("005930", 2)] + reason = env.th.await_args.args[0]["exit_reason"] + assert reason.startswith("소량 전량매도(분할 신호 50% → 실제 2/2주 전량") + assert "1차 익절(50%)" in reason + + +@pytest.mark.asyncio +async def test_kis_capped_full_signal_records_partial_reason(monkeypatch): + env = _env(monkeypatch, qty=10, hldg=10, sellable=4, price=8000) + await _cycle(env.es) + assert env.sent == [("005930", 4)] + reason = env.th.await_args.args[0]["exit_reason"] + assert reason.startswith("KIS 상한 부분매도(전량 신호 → 실제 4/10주)") + + +# ═══ #5 — 판정 대상 밖 보유(휴면) 경보 ══════════════════════════════════════ + +def _unowned_th(): + import app.services.swing.exit_service as es + return es._exit_alert_threshold_v2("SWING_EXIT_UNOWNED_ALERT_CYCLES", es._DEFAULT_EXIT_UNOWNED_ALERT_CYCLES) + + +@pytest.mark.asyncio +async def test_empty_holdings_with_untagged_portfolio_alerts_exit_zero_eval(monkeypatch, _reset): + """[재현 반전] 2026-05~09 휴면 유형 — 이전: 빈 보유 = 성공, warning 로그뿐.""" + env = _env(monkeypatch, codes=()) + db = _db(rows=[("005930", None), ("000660", "legacy_tag"), ("035420", "external")]) + th = _unowned_th() + for _ in range(th - 1): + await _cycle(env.es, db) + assert not any(k == "exit_zero_eval" for k, _ in _reset.alerts) + await _cycle(env.es, db) + assert any(k == "exit_zero_eval" and "000660" in m and "005930" in m for k, m in _reset.alerts) + h = env.es.get_swing_exit_health_v2() + assert "exit_zero_eval" in h["degraded_reasons"] + assert h["unowned_streak"] == th and h["empty_holdings_streak"] == th + assert h["census"]["unowned"] == ["000660", "005930"] + + +@pytest.mark.asyncio +async def test_known_non_swing_tags_do_not_alert_and_reset_streak(monkeypatch, _reset): + env = _env(monkeypatch, codes=()) + env.es._unowned_streak = 99 + db = _db(rows=[("035420", "external"), ("105560", "daytrading"), ("AAPL", "us_swing"), ("000270", "manual")]) + await _cycle(env.es, db) + assert env.es._unowned_streak == 0 + assert _reset.alerts == [] + assert env.es.get_swing_exit_health_v2()["degraded_reasons"] == [] + + +@pytest.mark.asyncio +async def test_unowned_with_swing_holdings_reports_unowned_reason(monkeypatch, _reset): + env = _env(monkeypatch, codes=("005930",), price=9900) # 손절 아님 + monkeypatch.setattr(env.es, "check_exit_signal", lambda **kw: None) + db = _db(rows=[("005930", "swing"), ("000660", None)]) + for _ in range(_unowned_th()): + await _cycle(env.es, db) + reasons = env.es.get_swing_exit_health_v2()["degraded_reasons"] + assert "exit_unowned_holdings" in reasons and "exit_zero_eval" not in reasons + + +@pytest.mark.asyncio +async def test_census_failure_does_not_block_stop_loss(monkeypatch, _reset): + env = _env(monkeypatch, price=8000) + env.es._unowned_streak = 3 + await _cycle(env.es, _db(exc=RuntimeError("pool exhausted"))) + assert env.sent == [("005930", 10)] # 청산 우선 + assert env.es._unowned_streak == 3 # 판단 불가 — 상태 유지 + assert env.es.get_last_exit_run_status_v2()["ok"] is True + + +# ═══ #6 — 잔고 첫 페이지 포화 근접 ══════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_balance_page_near_full_alerts(monkeypatch, _reset): + env = _env(monkeypatch, codes=()) + warn = env.es._balance_page_warn_rows_v2() + assert warn == int(settings.SWING_BALANCE_FULL_PAGE_ROWS) - env.es._BALANCE_PAGE_WARN_MARGIN + rows = [(f"{i:06d}", "external") for i in range(warn - 1)] + [("AAPL", "us_swing")] + await _cycle(env.es, _db(rows=rows)) + assert "balance_page_near_full" not in env.es.get_swing_exit_health_v2()["degraded_reasons"] + rows = [(f"{i:06d}", "external") for i in range(warn)] + await _cycle(env.es, _db(rows=rows)) + assert any(k == "balance_page_near_full" for k, _ in _reset.alerts) + assert "balance_page_near_full" in env.es.get_swing_exit_health_v2()["degraded_reasons"] diff --git a/backend/tests/test_swing_exit_hardening_r2_20261009.py b/backend/tests/test_swing_exit_hardening_r2_20261009.py new file mode 100644 index 0000000..8eea8ce --- /dev/null +++ b/backend/tests/test_swing_exit_hardening_r2_20261009.py @@ -0,0 +1,253 @@ +"""스윙 청산 하드닝 R2 (2026-10-09) 회귀 테스트. + +1. 결과 불명 매도 대기(_pending_unknown_sells)의 Redis 영속화·재기동 복원 + - 재기동 후 복원 → KIS 잔고 대조로 장부 반영, 같은 분할 단계 재발동 없음 + - 전량 체결 후 보유 목록에서 빠진 종목도 대조(유령 ActiveTrade 방지) + - Redis 일시 장애로 복원 실패 → 분할 익절만 1주기 보류, 손절은 진행 +2/3. 체결 확인·Portfolio sync 백그라운드 태스크가 trade_executor 추적 레지스트리 + (spawn_tracked_task_v2 → drain_tracked_tasks_v2) 를 탄다 + +규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order) — r5 테스트의 _Sim 재사용. +""" +import asyncio +import logging +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.core.config import settings # noqa: F401 (기대값은 settings/모듈 상수 참조) +from app.services.mean_reversion_strategy import ExitSignal +from test_swing_r5_pending_sell_20261002 import ( # noqa: F401 (_reset: autouse 픽스처) + UNKNOWN, _Sim, _partial_signal, _reset, +) + + +class _FakeRedis: + """hset/hdel/hgetall/expire 만 흉내. fail_reads 회만큼 hgetall 이 예외.""" + + def __init__(self, fail_reads=0): + self.h: dict[str, dict[str, str]] = {} + self.fail_reads = fail_reads + self.reads = 0 + + async def hset(self, key, field, value): + self.h.setdefault(key, {})[field] = value + + async def hdel(self, key, field): + self.h.get(key, {}).pop(field, None) + + async def expire(self, key, ttl): + return True + + async def hgetall(self, key): + self.reads += 1 + if self.reads <= self.fail_reads: + raise ConnectionError("redis down") + return dict(self.h.get(key, {})) + + +@pytest.fixture(autouse=True) +def _r2_reset(monkeypatch): + import app.services.swing.exit_service as es + monkeypatch.setattr(es, "_unknown_sells_restore_state", "pending") + monkeypatch.setattr(es, "_unknown_sells_restore_failures", 0) + # 체결 확인 백그라운드(delay=3s 후 KIS 조회)가 테스트 종료 뒤 남지 않게 — 기록형 목 + import app.services.trade_executor as te + monkeypatch.setattr(te, "_verify_and_sync_execution_v3", AsyncMock()) + + +def _use_redis(monkeypatch, fake): + import app.core.redis as r + monkeypatch.setattr(r, "get_redis", lambda: fake) + + +def _restart(es): + """프로세스 재기동 흉내 — 인메모리 상태만 사라진다(Redis·KIS·DB 장부는 유지).""" + es._pending_unknown_sells.clear() + es._recent_exits.clear() + es._unknown_sells_restore_state = "pending" + es._unknown_sells_restore_failures = 0 + + +# ── 1. 영속화·복원 ─────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_unknown_partial_survives_restart_no_stage_refire(monkeypatch): + """[재현 반전] 50% 익절 10주 결과 불명(실제 체결) 직후 재기동. 이전: 대기 소실 → + remaining_pct 1.0 그대로 → 같은 단계 재발동으로 10주 추가 매도. 이제: Redis 복원 → + 대조로 SELL 기록·remaining_pct=0.5 → 재발동 없음.""" + fake = _FakeRedis() + _use_redis(monkeypatch, fake) + sim = _Sim(monkeypatch, swing=20, dt=5, price=11500, signal=_partial_signal, responses=[UNKNOWN]) + await sim.cycles(1) + es = sim.es + assert "005930" in es._pending_unknown_sells + key = es._unknown_sells_redis_key_v2() + assert "005930" in fake.h[key] # 등록 시 미러링 + + _restart(es) + await sim.cycles(3) + + assert sum(sim.sent) == 10 + assert sim.at.remaining_pct == 0.5 + assert any("결과 불명" in (p.get("exit_reason") or "") for p in sim.history) + assert "005930" not in fake.h.get(key, {}) # 대조 후 Redis 에서도 해제 + assert es._pending_unknown_sells == {} + + +@pytest.mark.asyncio +async def test_unknown_full_fill_restart_row_gone_closes_active_trade(monkeypatch): + """전량 손절 불명(체결) → 재기동 → sync 가 Portfolio 행을 지워 보유 목록에 없음. + 이전: 루프에 안 들어와 대조 불가 → ActiveTrade 유령. 이제: 보유 목록 밖 대조로 close+loss.""" + fake = _FakeRedis() + _use_redis(monkeypatch, fake) + sim = _Sim(monkeypatch, swing=10, dt=0, responses=[UNKNOWN]) + await sim.cycles(1) + es = sim.es + assert sim.kis["hldg"] == 0 and sim.closed == 0 + + _restart(es) + monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=[])) + await sim.cycles(1) + + assert sim.closed == 1 + sim.loss.assert_awaited() + assert es._pending_unknown_sells == {} + assert fake.h.get(es._unknown_sells_redis_key_v2(), {}) == {} + + +@pytest.mark.asyncio +async def test_memory_entry_wins_over_redis_on_restore(monkeypatch): + import app.services.swing.exit_service as es + fake = _FakeRedis() + _use_redis(monkeypatch, fake) + stale = {"qty": 99, "pre_hldg": 99, "price": 1, "avg_price": 1, "swing_qty": 99, "sell_pct": 1.0, + "remaining_pct": 1.0, "original_qty": 99, "filter_not_dt": False, + "had_active_trade": True, "stock_name": "T", "reason": "x"} + await es._persist_unknown_sell_v2("005930", stale) + es._pending_unknown_sells["005930"] = dict(stale, qty=5) + assert await es._restore_unknown_sells_v2() is False + assert es._pending_unknown_sells["005930"]["qty"] == 5 + + +@pytest.mark.asyncio +async def test_malformed_redis_entry_skipped_with_error(monkeypatch, caplog): + import app.services.swing.exit_service as es + fake = _FakeRedis() + fake.h[es._unknown_sells_redis_key_v2()] = {"005930": "{not json", "000660": '{"qty": 3}'} + _use_redis(monkeypatch, fake) + with caplog.at_level(logging.ERROR): + assert await es._restore_unknown_sells_v2() is False + assert es._pending_unknown_sells == {} + assert es._unknown_sells_restore_state == "ok" + assert sum("형식 이상" in r.getMessage() for r in caplog.records) == 2 + + +# ── 1. 복원 실패 시 분할 익절 1주기 보류 ───────────────────────────────────── + +@pytest.mark.asyncio +async def test_restore_failure_holds_partial_one_cycle_then_restores(monkeypatch): + import app.services.swing.exit_service as es + _use_redis(monkeypatch, _FakeRedis(fail_reads=es._UNKNOWN_SELL_RESTORE_HOLD_CYCLES)) + sim = _Sim(monkeypatch, swing=20, dt=0, price=11500, signal=_partial_signal) + await sim.cycles(es._UNKNOWN_SELL_RESTORE_HOLD_CYCLES) + assert sim.sent == [] # 분할 익절 보류 + await sim.cycles(1) # 재시도 성공 → 정상 진행 + assert sim.sent == [10] + assert es._unknown_sells_restore_state == "ok" + + +@pytest.mark.asyncio +async def test_restore_failure_does_not_hold_stop_loss(monkeypatch): + """복원 실패 중에도 손절(전량)은 진행 — 청산 우선(§8.1-11).""" + import app.services.swing.exit_service as es + _use_redis(monkeypatch, _FakeRedis(fail_reads=10)) + sim = _Sim(monkeypatch, swing=10, dt=0, price=8000, avg=10000) + await sim.cycles(1) + assert sim.sent == [10] + assert es._unknown_sells_restore_failures == 1 + + +@pytest.mark.asyncio +async def test_restore_gives_up_after_hold_cycles(monkeypatch, caplog): + import app.services.swing.exit_service as es + _use_redis(monkeypatch, _FakeRedis(fail_reads=100)) + sim = _Sim(monkeypatch, swing=20, dt=0, price=11500, signal=_partial_signal) + with caplog.at_level(logging.WARNING): + await sim.cycles(es._UNKNOWN_SELL_RESTORE_HOLD_CYCLES + 1) + assert sim.sent == [10] # 보류는 HOLD_CYCLES 주기만 + assert es._unknown_sells_restore_state == "gave_up" + assert any(r.levelno >= logging.ERROR and "복원 포기" in r.getMessage() for r in caplog.records) + + +@pytest.mark.asyncio +async def test_redis_unconnected_no_hold(monkeypatch): + import app.services.swing.exit_service as es + _use_redis(monkeypatch, None) + sim = _Sim(monkeypatch, swing=20, dt=0, price=11500, signal=_partial_signal) + await sim.cycles(1) + assert sim.sent == [10] + assert es._unknown_sells_restore_state == "ok" + + +# ── 2/3. 백그라운드 태스크 추적 ───────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_fill_check_task_is_tracked_and_drained(monkeypatch): + import app.services.swing.exit_service as es + import app.services.trade_executor as te + gate = asyncio.Event() + calls = [] + + async def _verify(*a, **kw): + calls.append((a, kw)) + await gate.wait() + monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _verify) + monkeypatch.setattr(te, "_tracked_bg_tasks", set()) + + es._schedule_swing_sell_fill_check_v2("0001", "005930", "T", 10, 11500.0, 10000.0, "KRX") + tracked = [t for t in te._tracked_bg_tasks if not t.done()] + assert len(tracked) == 1 + assert tracked[0].get_name() == "swing_sell_fill:005930" + assert te.pending_tracked_tasks_count_v2() == 1 + + await asyncio.sleep(0) + gate.set() + assert await te.drain_tracked_tasks_v2(1.0) == 0 + assert calls and calls[0][1]["sell_pnl_basis"]["strategy_type"] == "swing" + assert te._tracked_bg_tasks == set() + + +@pytest.mark.asyncio +async def test_unknown_sell_portfolio_sync_task_is_tracked(monkeypatch): + import app.services.swing.exit_service as es + import app.services.trade_executor as te + monkeypatch.setattr(te, "_tracked_bg_tasks", set()) + sync = AsyncMock() + monkeypatch.setattr(te, "_delayed_sync_portfolio", sync) + es._trigger_portfolio_sync_v2() + assert len(te._tracked_bg_tasks) == 1 + assert next(iter(te._tracked_bg_tasks)).get_name() == "swing_unknown_sell_sync" + await te.drain_tracked_tasks_v2(1.0) + sync.assert_awaited_once() + + +def test_fill_check_without_loop_warns_not_raises(monkeypatch, caplog): + """루프 밖 호출 — 예외 전파 없이 warning, 코루틴 미생성.""" + import app.services.swing.exit_service as es + import app.services.trade_executor as te + + def _boom(*a, **kw): + raise AssertionError("루프 없으면 코루틴을 만들면 안 된다") + monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _boom) + with caplog.at_level(logging.WARNING): + es._schedule_swing_sell_fill_check_v2("0001", "005930", "T", 10, 1.0, 1.0, "KRX") + assert any("체결 확인 예약 실패" in r.getMessage() for r in caplog.records) + + +def test_no_bare_create_task_in_swing_exit_service(): + import inspect + import app.services.swing.exit_service as es + src = inspect.getsource(es) + assert "create_task(" not in src + _ = ExitSignal, MagicMock # import 유지(타 테스트와 동일 헬퍼) diff --git a/backend/tests/test_swing_exit_hardening_r4_20261009.py b/backend/tests/test_swing_exit_hardening_r4_20261009.py new file mode 100644 index 0000000..7d9dff1 --- /dev/null +++ b/backend/tests/test_swing_exit_hardening_r4_20261009.py @@ -0,0 +1,303 @@ +"""스윙 청산 하드닝 R4 (2026-10-09) — swing/exit_service 회귀 테스트. + +#1 [HIGH] '전체 오류'·'보유 종목 전부 예외'가 정상 반환되어 스케줄러가 매 사이클 성공으로 기록 → + 연속 실패·무완료 경보 우회. 이제 실행 결과(seq/ok)를 남기고 스케줄러가 실패로 집계한다. + 종목별 연속 예외 카운터 + 임계 경보. +#2 [MEDIUM] 스윙 매도 KIS 업무거부가 매 주기 무경보 재시도. 이제 종목별 연속 거부 카운터 + + 임계 경보, 계좌 단위 거부(KIS_ACCOUNT_REJECT_MSG_CODES)는 1회째 즉시 경보, /health degraded. + +규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order), 실패 주입은 예외 발생형, +임계·거부 코드는 settings/모듈 기본값 참조(하드코딩 금지). +""" +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import app.services.mean_reversion_strategy as mr +from app.core.config import settings +from app.services.mean_reversion_strategy import ExitSignal + +OK = {"rt_cd": "0", "output": {"ODNO": "9"}} + + +class _SessionCM: + def __init__(self, db=None): + self.db = db if db is not None else MagicMock() + + async def __aenter__(self): + return self.db + + async def __aexit__(self, *a): + return False + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch): + import app.services.swing.exit_service as es + import app.services.swing.scheduler as sch + import app.services.telegram_notifier as tn + for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr", + "_presell_check_failures", "_no_price_skips", "_recent_exits", + "_swing_exit_error_counts", "_swing_sell_reject_counts", "_swing_sell_reject_last"): + monkeypatch.setattr(es, name, {}) + monkeypatch.setattr(es, "_exit_run_state_v2", { + "seq": 0, "ok": True, "error": None, "holdings": 0, "errored": 0, "errors": [], + }) + from app.core import log_throttle + log_throttle.reset_for_tests() + import app.services.adaptive_strategy as ad + monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True)) + monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None) + import app.services.trade_executor as te + monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) + # 텔레그램 실전송 차단(예외 발생형) + 경보 수집 + monkeypatch.setattr(tn.telegram_notifier, "send_message", + AsyncMock(side_effect=AssertionError("텔레그램 실전송 금지"))) + sch._exit_health.update({ + "last_success_at": None, "market_open_since": None, + "consecutive_failures": 0, "last_error": None, "timeouts": 0, + }) + monkeypatch.setattr(sch, "_alert_sent_at", {}) + alerts = [] + monkeypatch.setattr(sch, "_notify_throttled_v2", + lambda key, category, message, repeat_sec=None: alerts.append((key, message)) or True) + return SimpleNamespace(es=es, sch=sch, alerts=alerts) + + +def _holding(code, price): + from app.core.timeutil import now_kst + return SimpleNamespace(stock_code=code, stock_name="T", quantity=10, average_price=10000, + current_price=price, market="KRX", updated_at=now_kst()) + + +def _env(monkeypatch, *, codes=("005930",), price=8000, order=None, holdings_exc=None, at_exc=None): + """기본: 평균 10000 → 현재가 8000 손절 신호(check_exit_signal 목) → 매도 주문.""" + import app.services.swing.exit_service as es + import app.services.trade_executor as te + import app.services.db_worker as dbw + import app.services.portfolio_cache_bridge as pcb + from app.core.timeutil import now_kst + from app.services.daytrading.position_manager import day_trading_state + + holdings = [_holding(c, price) for c in codes] + if holdings_exc is not None: + monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(side_effect=holdings_exc)) + else: + monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=holdings)) + monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"})) + at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None, + created_at=now_kst() - timedelta(days=2), entry_reason=None, entry_price=10000) + if at_exc is not None: + monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(side_effect=at_exc)) + else: + monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at)) + api = MagicMock() + bars = [{"stck_clpr": str(price + (i % 3) * 50), "stck_hgpr": str(price + 200 + (i % 3) * 50), + "stck_lwpr": str(price - 200 + (i % 3) * 50)} for i in range(30)] + api.get_daily_chart = AsyncMock(return_value={"output2": bars}) + api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}}) + api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ + {"pdno": c, "hldg_qty": "10", "ord_psbl_qty": "10"} for c in codes]}) + monkeypatch.setattr(es, "korea_investment_api", api) + monkeypatch.setattr(es, "check_exit_signal", lambda **kw: ExitSignal( + action="SELL_ALL", reason="손절 테스트", sell_pct=1.0)) + monkeypatch.setattr(day_trading_state, "positions", {}) + monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock()) + sent = [] + state = {"order": order} + + async def _spo(**kw): + sent.append((kw["stock_code"], kw["quantity"])) + return state["order"] + monkeypatch.setattr(te, "safe_place_order", _spo) + monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock()) + monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock()) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", AsyncMock()) + monkeypatch.setattr(dbw, "enqueue_db_write", AsyncMock()) + monkeypatch.setattr(pcb, "reflect_sell", AsyncMock()) + monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", MagicMock()) + monkeypatch.setattr(es, "_prior_position_realized_net_v2", AsyncMock(return_value=None)) + monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", AsyncMock()) + monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock()) + return SimpleNamespace(es=es, sent=sent, state=state) + + +async def _cycle(es): + es._recent_exits.clear() + return await es.check_and_execute_exit_v2(MagicMock()) + + +# ═══════════════════════════════════════════════════════════════════════════ +# #1 — 전체 오류 / 전 종목 예외가 실패로 집계 +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_total_error_recorded_as_failed_run(monkeypatch, _reset): + env = _env(monkeypatch, holdings_exc=RuntimeError("QueuePool limit reached")) + exits, ok, errors = await env.es.check_and_execute_exit_v2_impl(MagicMock()) + assert exits == [] and ok is False + assert any("QueuePool" in e for e in errors) + st = env.es.get_last_exit_run_status_v2() + assert st["seq"] == 1 and st["ok"] is False + assert "exit_run_failed" in env.es.get_swing_exit_health_v2()["degraded_reasons"] + + +@pytest.mark.asyncio +async def test_scheduler_records_failure_on_swallowed_total_error(monkeypatch, _reset): + """[재현 반전] 이전: 전체 오류인데 _record_exit_success_v2 → 경보 영구 미발동.""" + import app.services.swing.fill_sweep as fs + sch = _reset.sch + _env(monkeypatch, holdings_exc=RuntimeError("db pool exhausted")) + monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM()) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(return_value=0)) + threshold = sch._DEFAULT_EXIT_FAIL_ALERT_COUNT + for _ in range(threshold): + await sch._run_exit_phase_v2() + assert sch._exit_health["last_success_at"] is None + assert sch._exit_health["consecutive_failures"] == threshold + assert "db pool exhausted" in str(sch._exit_health["last_error"]) + assert any(k == "exit_failures" for k, _ in _reset.alerts) + + +@pytest.mark.asyncio +async def test_scheduler_success_when_run_ok(monkeypatch, _reset): + import app.services.swing.fill_sweep as fs + sch = _reset.sch + _env(monkeypatch, codes=()) # 무보유 — 정상 + monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM()) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(return_value=0)) + sch._exit_health["consecutive_failures"] = 2 + await sch._run_exit_phase_v2() + assert sch._exit_health["consecutive_failures"] == 0 + assert sch._exit_health["last_success_at"] is not None + + +@pytest.mark.asyncio +async def test_scheduler_mocked_exit_keeps_success_semantics(monkeypatch, _reset): + """목으로 대체돼 실행 seq 가 안 바뀌면(이번 실행 결과 없음) 이전 실패 상태에 끌려가지 않는다.""" + import app.services.swing.fill_sweep as fs + sch, es = _reset.sch, _reset.es + es._exit_run_state_v2.update({"seq": 5, "ok": False, "error": "old"}) + monkeypatch.setattr(sch, "check_and_execute_exit_v2", AsyncMock(return_value=[])) + monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM()) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(return_value=0)) + await sch._run_exit_phase_v2() + assert sch._exit_health["consecutive_failures"] == 0 + + +@pytest.mark.asyncio +async def test_all_holdings_raising_is_failed_run(monkeypatch, _reset): + env = _env(monkeypatch, codes=("005930", "000660"), order=OK, at_exc=RuntimeError("orm boom")) + exits, ok, errors = await env.es.check_and_execute_exit_v2_impl(MagicMock()) + assert ok is False and exits == [] and env.sent == [] + st = env.es.get_last_exit_run_status_v2() + assert st["holdings"] == 2 and st["errored"] == 2 + assert "전부" in st["error"] + + +@pytest.mark.asyncio +async def test_partial_symbol_error_run_ok_but_symbol_alert_at_threshold(monkeypatch, _reset): + env = _env(monkeypatch, codes=("005930", "000660"), order=OK) + es = env.es + real_at = es.get_swing_active_trade + + async def _at(db, code, *a, **kw): + if code == "000660": + raise RuntimeError("bad row") + return await real_at(db, code, *a, **kw) + monkeypatch.setattr(es, "get_swing_active_trade", _at) + threshold = es._exit_alert_threshold_v2( + "SWING_EXIT_SYMBOL_ERROR_ALERT_COUNT", es._DEFAULT_EXIT_SYMBOL_ERROR_ALERT_COUNT) + for i in range(threshold): + _ok_before = [k for k, _ in _reset.alerts] + await _cycle(es) + st = es.get_last_exit_run_status_v2() + assert st["ok"] is True and st["errored"] == 1 + if i < threshold - 1: + assert "exit_symbol_error:000660" not in _ok_before + assert es._swing_exit_error_counts["000660"] == threshold + assert "005930" not in es._swing_exit_error_counts + assert ("exit_symbol_error:000660" in [k for k, _ in _reset.alerts]) + assert "exit_symbol_errors" in es.get_swing_exit_health_v2()["degraded_reasons"] + # 복구되면 카운터 해제 + monkeypatch.setattr(es, "get_swing_active_trade", real_at) + await _cycle(es) + assert "000660" not in es._swing_exit_error_counts + assert "exit_symbol_errors" not in es.get_swing_exit_health_v2()["degraded_reasons"] + + +# ═══════════════════════════════════════════════════════════════════════════ +# #2 — 매도 거부 연속 집계 · 경보 · health +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_sell_reject_counts_and_alerts_at_threshold_then_clears(monkeypatch, _reset): + reject = {"rt_cd": "1", "msg_cd": "APBK0919", "msg1": "거래정지 종목"} + env = _env(monkeypatch, order=reject) + es = env.es + threshold = es._exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", es._DEFAULT_SELL_REJECT_ALERT_COUNT) + for i in range(threshold): + await _cycle(es) + assert es._swing_sell_reject_counts["005930"] == i + 1 + keys = [k for k, _ in _reset.alerts] + assert ("sell_reject:005930" in keys) == (i + 1 >= threshold) + assert len(env.sent) == threshold # 손절 재시도 자체는 계속(가드가 매도를 막지 않는다) + h = es.get_swing_exit_health_v2() + assert "swing_sell_rejects" in h["degraded_reasons"] + assert "swing_sell_account_reject" not in h["degraded_reasons"] + assert "swing_sell_rejects" in _reset.sch.get_swing_scheduler_health_v2()["degraded_reasons"] + # 접수 성공 → 해제 + env.state["order"] = OK + await _cycle(es) + assert "005930" not in es._swing_sell_reject_counts + assert "swing_sell_rejects" not in es.get_swing_exit_health_v2()["degraded_reasons"] + + +@pytest.mark.asyncio +async def test_account_reject_alerts_immediately(monkeypatch, _reset): + codes = list(settings.KIS_ACCOUNT_REJECT_MSG_CODES or []) + assert codes, "KIS_ACCOUNT_REJECT_MSG_CODES 가 비어 있으면 이 경보는 비활성" + env = _env(monkeypatch, order={"rt_cd": "1", "msg_cd": codes[0], "msg1": "계좌 거부"}) + await _cycle(env.es) + keys = [k for k, _ in _reset.alerts] + assert keys.count("sell_reject_account") == 1 + h = env.es.get_swing_exit_health_v2() + assert "swing_sell_account_reject" in h["degraded_reasons"] + assert "swing_sell_account_reject" in _reset.sch.get_swing_scheduler_health_v2()["degraded_reasons"] + + +@pytest.mark.asyncio +async def test_reject_counter_pruned_when_holding_gone(monkeypatch, _reset): + env = _env(monkeypatch, order={"rt_cd": "1", "msg_cd": "APBK0919", "msg1": "x"}) + await _cycle(env.es) + assert env.es._swing_sell_reject_counts.get("005930") == 1 + _env(monkeypatch, codes=()) + await _cycle(env.es) + assert env.es._swing_sell_reject_counts == {} + + +@pytest.mark.asyncio +async def test_notify_failure_does_not_break_exit(monkeypatch, _reset): + """경보 예약이 예외를 내도 카운터는 갱신되고 청산 루프는 계속(가드는 try 밖).""" + sch = _reset.sch + + def _boom(*a, **kw): + raise RuntimeError("no loop") + monkeypatch.setattr(sch, "_notify_throttled_v2", _boom) + env = _env(monkeypatch, codes=("005930", "000660"), + order={"rt_cd": "1", "msg_cd": list(settings.KIS_ACCOUNT_REJECT_MSG_CODES)[0], "msg1": "x"}) + await _cycle(env.es) + assert env.es._swing_sell_reject_counts == {"005930": 1, "000660": 1} + assert env.es.get_last_exit_run_status_v2()["ok"] is True + + +def test_threshold_rejects_disable_values(monkeypatch): + import app.services.swing.exit_service as es + monkeypatch.setattr(es, "settings", SimpleNamespace(SWING_SELL_REJECT_ALERT_COUNT=0)) + assert es._exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", 3) == 3 + monkeypatch.setattr(es, "settings", SimpleNamespace(SWING_SELL_REJECT_ALERT_COUNT="abc")) + assert es._exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", 3) == 3 + monkeypatch.setattr(es, "settings", SimpleNamespace(SWING_SELL_REJECT_ALERT_COUNT=5)) + assert es._exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", 3) == 5 diff --git a/backend/tests/test_swing_exit_rsi_warmup_r4_20261009.py b/backend/tests/test_swing_exit_rsi_warmup_r4_20261009.py new file mode 100644 index 0000000..3a67f67 --- /dev/null +++ b/backend/tests/test_swing_exit_rsi_warmup_r4_20261009.py @@ -0,0 +1,297 @@ +"""스윙 청산 RSI 워밍업 (하드닝 R4 2026-10-09) — swing/exit_service 회귀 테스트. + +[MEDIUM] 청산 RSI 가 KIS 30봉 일봉으로 계산돼 Wilder RSI(14) 시드 영향이 남았다(운영 실측 +RSI≥55 판정 불일치 6.9%). 이제 장기 이력(네이버 → KIS 기간 조회)을 하루 1회 캐시하고 KIS 30봉과 +날짜로 병합해 RSI 를 계산한다. 실패·불일치·대기 초과면 기존 30봉 RSI 로 폴백한다. + +규약(CLAUDE.md §4.1): 외부 조회 목은 예외 발생형 또는 명시적 반환, 주문 목은 공통 종단. +""" +import asyncio +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import app.services.mean_reversion_strategy as mr +from app.services.mean_reversion_strategy import ExitSignal, calculate_rsi + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch): + import app.services.swing.exit_service as es + for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr", + "_presell_check_failures", "_no_price_skips", "_recent_exits", + "_swing_exit_error_counts", "_swing_sell_reject_counts", "_swing_sell_reject_last", + "_exit_history_cache", "_exit_history_tasks"): + monkeypatch.setattr(es, name, {}) + monkeypatch.setattr(es, "_exit_rsi_source_counts", {"merged": 0, "kis30": 0}) + from app.core import log_throttle + log_throttle.reset_for_tests() + # 실수로 외부 조회가 일어나면 즉시 실패(예외 발생형). 개별 테스트가 필요 시 덮는다. + import app.services.naver_finance as nf + monkeypatch.setattr(nf, "get_naver_daily_chart", + AsyncMock(side_effect=AssertionError("네이버 실호출 금지"))) + return es + + +def _dates(n, end=None): + from app.core.timeutil import today_kst + end = end or today_kst() + out, d = [], end + while len(out) < n: + if d.weekday() < 5: + out.append(d.strftime("%Y%m%d")) + d -= timedelta(days=1) + return list(reversed(out)) + + +def _series(n): + # 결정적 등락 — 30봉 RSI 와 전체 이력 RSI 가 확실히 갈리도록 앞쪽에 강한 추세를 둔다. + closes, p = [], 10000.0 + for i in range(n): + p += (120 if i < n - 40 else 0) + (((i * 37) % 11) - 5) * 30 + closes.append(round(p, 0)) + return closes + + +def _bars(dates, closes): + return [{"stck_bsop_date": d, "stck_clpr": str(c), "stck_hgpr": str(c + 100), + "stck_lwpr": str(c - 100)} for d, c in zip(dates, closes)] + + +def _fixture_bars(n_hist=130, n_recent=30): + dates = _dates(n_hist) + closes = _series(n_hist) + history = _bars(dates[:-1], closes[:-1]) # 이력은 어제까지 + recent = _bars(dates[-n_recent:], closes[-n_recent:]) # KIS 30봉 (오늘 포함) + return history, recent, closes + + +# ═══════════════════════════════════════════════════════════════════════════ +# 병합 순수 로직 +# ═══════════════════════════════════════════════════════════════════════════ + +def test_merge_gives_full_history_rsi(_reset): + es = _reset + history, recent, closes = _fixture_bars() + merged = es._merge_exit_bars_v2(history, recent) + assert merged is not None and len(merged) == len(closes) + rsi_full = calculate_rsi([float(b["stck_clpr"]) for b in merged]) + assert rsi_full == pytest.approx(calculate_rsi(closes)) + # 결함 재현: 30봉 RSI 는 전체 이력 RSI 와 다르다 + assert abs(calculate_rsi(closes[-30:]) - rsi_full) > 0.5 + + +def test_merge_today_bar_comes_from_recent_even_if_history_differs(_reset): + es = _reset + history, recent, closes = _fixture_bars() + # 이력에 오늘 봉이 섞여 있고 값이 다르다(장중 진행) → 최근 봉이 이기고 검증 대상에서 제외 + today = recent[-1]["stck_bsop_date"] + history = history + [{"stck_bsop_date": today, "stck_clpr": str(closes[-1] * 1.05)}] + merged = es._merge_exit_bars_v2(history, recent) + assert merged is not None and merged[-1] is recent[-1] + + +def test_merge_rejects_close_mismatch(_reset): + es = _reset + history, recent, _ = _fixture_bars() + # 겹치는 확정 봉 하나의 종가가 허용 오차를 넘게 다르다(수정주가 차이 등) + bad = dict(recent[5]) + tol = es._EXIT_HISTORY_CLOSE_TOL + bad["stck_clpr"] = str(float(bad["stck_clpr"]) * (1 + tol * 3)) + recent = recent[:5] + [bad] + recent[6:] + assert es._merge_exit_bars_v2(history, recent) is None + + +def test_merge_rejects_no_overlap_or_no_older(_reset): + es = _reset + history, recent, _ = _fixture_bars() + # 겹침 없음(이력이 최근 봉 시작 이전에서 끊김) + first = recent[0]["stck_bsop_date"] + gap_hist = [b for b in history if b["stck_bsop_date"] < first] + assert es._merge_exit_bars_v2(gap_hist, recent) is None + # 더 이른 봉이 없음(이득 없음) + only_overlap = [b for b in history if b["stck_bsop_date"] >= first] + assert es._merge_exit_bars_v2(only_overlap, recent) is None + assert es._merge_exit_bars_v2(None, recent) is None + + +# ═══════════════════════════════════════════════════════════════════════════ +# _exit_rsi_v2 — 조회·캐시·폴백 +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_exit_rsi_uses_history_and_caches_daily(monkeypatch, _reset): + es = _reset + history, recent, closes = _fixture_bars() + fetch = AsyncMock(return_value=(history, "naver")) + monkeypatch.setattr(es, "_fetch_exit_history_bars_v2", fetch) + c30 = [float(b["stck_clpr"]) for b in recent] + r1 = await es._exit_rsi_v2("005930", recent, c30) + r2 = await es._exit_rsi_v2("005930", recent, c30) + assert r1 == pytest.approx(calculate_rsi(closes)) and r2 == r1 + assert fetch.await_count == 1 # 하루 1회 + assert es._exit_rsi_source_counts["merged"] == 2 + + +@pytest.mark.asyncio +async def test_exit_rsi_falls_back_when_sources_raise(monkeypatch, _reset): + """네이버·KIS 기간 조회가 모두 예외 → 30봉 RSI, 실패는 재시도 간격 동안 캐시.""" + es = _reset + import app.services.naver_finance as nf + naver = AsyncMock(side_effect=RuntimeError("naver down")) + monkeypatch.setattr(nf, "get_naver_daily_chart", naver) + api = MagicMock() + api._quote_delegate = lambda: None + api.get_daily_ohlcv_range = AsyncMock(side_effect=RuntimeError("kis down")) + monkeypatch.setattr(es, "korea_investment_api", api) + _, recent, _ = _fixture_bars() + c30 = [float(b["stck_clpr"]) for b in recent] + r = await es._exit_rsi_v2("005930", recent, c30) + assert r == pytest.approx(calculate_rsi(c30)) + r = await es._exit_rsi_v2("005930", recent, c30) + assert naver.await_count == 1 and api.get_daily_ohlcv_range.await_count == 1 + assert es._exit_rsi_source_counts["kis30"] == 2 + + +@pytest.mark.asyncio +async def test_exit_rsi_kis_range_used_when_naver_fails(monkeypatch, _reset): + es = _reset + import app.services.naver_finance as nf + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(side_effect=RuntimeError("x"))) + history, recent, closes = _fixture_bars() + api = MagicMock() + api._quote_delegate = lambda: None + api.get_daily_ohlcv_range = AsyncMock(return_value=list(reversed(history))) # KIS 는 최신→과거 + monkeypatch.setattr(es, "korea_investment_api", api) + c30 = [float(b["stck_clpr"]) for b in recent] + r = await es._exit_rsi_v2("005930", recent, c30) + assert r == pytest.approx(calculate_rsi(closes)) + + +@pytest.mark.asyncio +async def test_exit_rsi_slow_fetch_does_not_block_cycle(monkeypatch, _reset): + """조회가 대기 상한을 넘기면 이번 사이클은 30봉 RSI, 완료 후 다음 사이클에 병합 RSI.""" + es = _reset + history, recent, closes = _fixture_bars() + gate = asyncio.Event() + + async def _slow(code): + await gate.wait() + return history, "naver" + monkeypatch.setattr(es, "_fetch_exit_history_bars_v2", _slow) + monkeypatch.setattr(es, "EXIT_HISTORY_WAIT_SEC", 0.01) + c30 = [float(b["stck_clpr"]) for b in recent] + r1 = await es._exit_rsi_v2("005930", recent, c30) + assert r1 == pytest.approx(calculate_rsi(c30)) + gate.set() + await asyncio.sleep(0) + for _ in range(5): + if not es._exit_history_tasks: + break + await asyncio.sleep(0.01) + r2 = await es._exit_rsi_v2("005930", recent, c30) + assert r2 == pytest.approx(calculate_rsi(closes)) + + +@pytest.mark.asyncio +async def test_exit_rsi_undated_bars_skip_fetch(monkeypatch, _reset): + es = _reset + fetch = AsyncMock(side_effect=AssertionError("날짜 없는 봉에서 이력 조회 금지")) + monkeypatch.setattr(es, "_fetch_exit_history_bars_v2", fetch) + recent = [{"stck_clpr": str(10000 + i * 10)} for i in range(30)] + c30 = [float(b["stck_clpr"]) for b in recent] + r = await es._exit_rsi_v2("005930", recent, c30) + assert r == pytest.approx(calculate_rsi(c30)) + assert fetch.await_count == 0 + + +# ═══════════════════════════════════════════════════════════════════════════ +# 청산 루프 통합 — check_exit_signal 에 전달되는 RSI, 손절 우선 +# ═══════════════════════════════════════════════════════════════════════════ + +def _loop_env(monkeypatch, es, recent, captured, signal=None): + import app.services.trade_executor as te + import app.services.db_worker as dbw + import app.services.portfolio_cache_bridge as pcb + import app.services.adaptive_strategy as ad + from app.core.timeutil import now_kst + from app.services.daytrading.position_manager import day_trading_state + + price = float(recent[-1]["stck_clpr"]) + monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True)) + monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None) + monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) + holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=10, + average_price=price, current_price=price, market="KRX", + updated_at=now_kst()) + monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=[holding])) + monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"})) + at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None, + created_at=now_kst() - timedelta(days=2), entry_reason=None, + entry_price=price) + monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at)) + api = MagicMock() + api._quote_delegate = lambda: None + api.get_daily_chart = AsyncMock(return_value={"output2": list(reversed(recent))}) + api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(int(price))}}) + api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ + {"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]}) + api.get_daily_ohlcv_range = AsyncMock(side_effect=RuntimeError("kis range down")) + monkeypatch.setattr(es, "korea_investment_api", api) + + def _ces(**kw): + captured.append(kw["rsi"]) + return signal + monkeypatch.setattr(es, "check_exit_signal", _ces) + monkeypatch.setattr(es, "_time_stop_signal_v2", lambda *a, **k: None) + monkeypatch.setattr(es, "_resolve_time_stop_entry_v3", AsyncMock(return_value=None)) + monkeypatch.setattr(day_trading_state, "positions", {}) + monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock()) + sent = [] + + async def _spo(**kw): + sent.append((kw["stock_code"], kw["quantity"])) + return {"rt_cd": "0", "output": {"ODNO": "9"}} + monkeypatch.setattr(te, "safe_place_order", _spo) + monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock()) + monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock()) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", AsyncMock()) + monkeypatch.setattr(dbw, "enqueue_db_write", AsyncMock()) + monkeypatch.setattr(pcb, "reflect_sell", AsyncMock()) + monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", MagicMock()) + monkeypatch.setattr(es, "_prior_position_realized_net_v2", AsyncMock(return_value=None)) + monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", AsyncMock()) + monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock()) + return sent + + +@pytest.mark.asyncio +async def test_exit_loop_passes_full_history_rsi(monkeypatch, _reset): + es = _reset + history, recent, closes = _fixture_bars() + import app.services.naver_finance as nf + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=history)) + captured = [] + _loop_env(monkeypatch, es, recent, captured) + es._recent_exits.clear() + await es.check_and_execute_exit_v2(MagicMock()) + assert captured and captured[0] == pytest.approx(calculate_rsi(closes)) + assert captured[0] != pytest.approx(calculate_rsi([float(b["stck_clpr"]) for b in recent])) + + +@pytest.mark.asyncio +async def test_exit_loop_history_failure_keeps_stop_loss(monkeypatch, _reset): + """이력 조회가 전부 실패해도 30봉 RSI 로 판정되고 손절 신호는 그대로 주문된다(§8.1-11).""" + es = _reset + _, recent, _ = _fixture_bars() + import app.services.naver_finance as nf + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(side_effect=RuntimeError("down"))) + captured = [] + sent = _loop_env(monkeypatch, es, recent, captured, + signal=ExitSignal(action="SELL_ALL", reason="손절 테스트", sell_pct=1.0)) + es._recent_exits.clear() + await es.check_and_execute_exit_v2(MagicMock()) + assert captured[0] == pytest.approx(calculate_rsi([float(b["stck_clpr"]) for b in recent])) + assert sent == [("005930", 10)] diff --git a/backend/tests/test_swing_hardening_r3_cap_20261009.py b/backend/tests/test_swing_hardening_r3_cap_20261009.py new file mode 100644 index 0000000..2e04f2b --- /dev/null +++ b/backend/tests/test_swing_hardening_r3_cap_20261009.py @@ -0,0 +1,157 @@ +"""[하드닝 R3 2026-10-09] 스윙 매도 직전 KIS 상한 — DT 이중 차감 / 상한 매도의 '전량 청산' 오처리. + +결함: 상한 = min(주문가능, 보유) − DT. 주문가능에는 대기 중인 매도 주문(DT 미체결 매도 포함)이 이미 +빠져 있어 DT 를 이중 차감했고, 상한에 걸려 일부만 판 매도도 `sell_qty >= quantity(캡 적용 후)` 로 +ActiveTrade close·loss_tracker·closed_today 까지 '전량 청산' 처리했다. +수정: 상한 = min(주문가능, 보유 − DT). 전량 판정은 상한 적용 전 스윙 보유(min(장부, 보유 − DT)) 대비. + +KIS 시뮬레이터·주문 목(공통 종단 safe_place_order)은 R5 테스트의 _Sim 을 재사용한다(§4.1). +""" +from unittest.mock import AsyncMock + +import pytest + +from app.services.mean_reversion_strategy import ExitSignal +from tests.test_swing_r5_pending_sell_20261002 import UNKNOWN, _Sim, _reset # noqa: F401 (autouse fixture) + + +def _stop(remaining): + return ExitSignal("SELL_ALL", "손절", 1.0) + + +def _partial(remaining): + return ExitSignal("SELL_PARTIAL", "1차 익절", 0.5) if remaining > 0.5 else None + + +def _track(monkeypatch, sim): + """ActiveTrade upsert 의 quantity·remaining_pct 와 closed_today 를 기록.""" + import app.services.db_worker as dbw + from app.services.daytrading.position_manager import day_trading_state + sim.upserts = [] + + async def _upsert(p): + sim.upserts.append(dict(p)) + if sim.at is not None and "remaining_pct" in p: + sim.at.remaining_pct = p["remaining_pct"] + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _upsert) + monkeypatch.setattr(day_trading_state, "closed_today", set()) + return day_trading_state + + +@pytest.mark.asyncio +async def test_dt_pending_sell_capped_sale_is_not_full_close(monkeypatch): + """[재현 반전] 스윙 10 + DT 5, DT 매도 5주 미체결(보유 15, 주문가능 10). 상한은 과다매도 방지를 + 위해 보수적으로 10−5=5 유지(대기분이 스윙 것일 수 있음). 이전: 5주만 팔고 '전량 청산' + (close·loss_tracker·closed_today). 이제: 분할 처리(잔량 5) → DT 대기 체결 후 잔량 5주 매도 시 전량 청산.""" + sim = _Sim(monkeypatch, swing=10, dt=5, signal=_stop) + dts = _track(monkeypatch, sim) + sim.kis = {"hldg": 15, "psbl": 10} + await sim.cycles(1) + assert sim.sent == [5] + assert sim.closed == 0 + sim.loss.assert_not_awaited() + assert "005930" not in dts.closed_today + assert sim.upserts[-1]["quantity"] == 5 + # DT 대기 매도 체결 → DT 인메모리 0, KIS 보유 = 스윙 잔량 5 + sim.kis = {"hldg": 5, "psbl": 5} + monkeypatch.setattr(dts, "positions", {}) + await sim.cycles(1) + assert sim.sent == [5, 5] and sim.kis["hldg"] == 0 + assert sim.closed == 1 + sim.loss.assert_awaited_once() + assert "005930" in dts.closed_today + + +@pytest.mark.asyncio +async def test_swing_own_open_order_not_resold_into_dt_shares(monkeypatch): + """상한을 보유 − DT 로 완화하면 스윙 자신의 미체결 매도(10주)와 재매도가 모두 체결돼 DT 10주까지 + 팔린다. 보수적 상한 유지 → 재매도 없음(R5 test_c1_open_order_at_broker_blocks_resell 과 같은 불변식).""" + sim = _Sim(monkeypatch, swing=10, dt=10, signal=_stop) + _track(monkeypatch, sim) + sim.kis = {"hldg": 20, "psbl": 10} # 스윙 10주 매도 주문이 브로커에 미체결 + await sim.cycles(2) + assert sim.sent == [] + + +@pytest.mark.asyncio +async def test_capped_stop_is_partial_until_actual_remainder_zero(monkeypatch): + """스윙 10, 지정가 익절 5주 미체결(보유 10, 주문가능 5)에서 손절. 이전: 5주 매도 + close + + loss_tracker + closed_today. 이제: 5주 매도 후 ActiveTrade 잔량 5·remaining_pct 0.5 만 갱신, + close·loss_tracker 없음. 묶인 주문이 풀리면 다음 주기 잔량 5주 매도 시 그때 전량 청산.""" + sim = _Sim(monkeypatch, swing=10, dt=0, signal=_stop) + dts = _track(monkeypatch, sim) + sim.kis = {"hldg": 10, "psbl": 5} + await sim.cycles(1) + assert sim.sent == [5] + assert sim.closed == 0 + sim.loss.assert_not_awaited() + assert "005930" not in dts.closed_today + assert sim.upserts[-1]["quantity"] == 5 + assert sim.upserts[-1]["remaining_pct"] == 0.5 # 단계 차감(1.0−1.0=0) 아님 — 실제 잔량 비율 + # 묶여 있는 동안(주문가능 0)에는 주문하지 않는다 + await sim.cycles(1) + assert sim.sent == [5] + # 지정가 취소 → 주문가능 회복 → 잔량 매도 = 이번엔 전량 청산 + sim.kis["psbl"] = sim.kis["hldg"] + await sim.cycles(1) + assert sim.sent == [5, 5] and sim.kis["hldg"] == 0 + assert sim.closed == 1 + sim.loss.assert_awaited_once() + assert "005930" in dts.closed_today + + +@pytest.mark.asyncio +async def test_capped_partial_take_profit_not_closed(monkeypatch): + """스윙 20 (보유 20, 주문가능 5)에서 50% 익절 10주 → 5주만 매도. 분할 단계는 소진(재발동 방지), + ActiveTrade 잔량 15, close·loss_tracker 없음.""" + sim = _Sim(monkeypatch, swing=20, dt=0, price=11500, signal=_partial) + _track(monkeypatch, sim) + sim.kis = {"hldg": 20, "psbl": 5} + await sim.cycles(1) + assert sim.sent == [5] + assert sim.closed == 0 + sim.loss.assert_not_awaited() + assert sim.upserts[-1]["quantity"] == 15 + assert sim.upserts[-1]["remaining_pct"] == 0.5 + await sim.cycles(2) + assert sim.sent == [5] # 같은 단계 재발동 없음 + + +@pytest.mark.asyncio +async def test_capped_stop_unknown_reconciled_as_partial(monkeypatch): + """상한에 걸린 손절이 결과 불명 → 체결 대조 시에도 스윙 보유(10) 대비 부분 체결로 반영.""" + sim = _Sim(monkeypatch, swing=10, dt=0, signal=_stop, responses=[UNKNOWN]) + _track(monkeypatch, sim) + sim.kis = {"hldg": 10, "psbl": 5} + await sim.cycles(1) + pend = sim.es._pending_unknown_sells["005930"] + assert pend["swing_qty"] == 10 and pend["sell_pct"] == 0.0 + await sim.cycles(1) + assert "005930" not in sim.es._pending_unknown_sells + assert sim.closed == 0 + sim.loss.assert_not_awaited() + assert sim.upserts[-1]["quantity"] == 5 + assert sim.upserts[-1]["remaining_pct"] == 0.5 + + +@pytest.mark.asyncio +async def test_inflated_ledger_still_closes_when_kis_swing_empty(monkeypatch): + """장부 스윙 10(Portfolio 15 − DT 5)인데 KIS 보유 12 → KIS 스윙 7주 전량 매도 = 전량 청산 유지.""" + sim = _Sim(monkeypatch, swing=10, dt=5, signal=_stop) + _track(monkeypatch, sim) + sim.kis = {"hldg": 12, "psbl": 12} + await sim.cycles(1) + assert sim.sent == [7] + assert sim.closed == 1 + sim.loss.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_dt_unreflected_buy_never_sells_dt_inmemory_qty(monkeypatch): + """DT 매수 직후 KIS 보유 미반영(보유 10, DT 인메모리 5) — 보수적으로 보유 − DT = 5 만 매도(과다매도 없음).""" + sim = _Sim(monkeypatch, swing=10, dt=5, signal=_stop) + _track(monkeypatch, sim) + sim.kis = {"hldg": 10, "psbl": 10} + await sim.cycles(1) + assert sim.sent == [5] + assert sim.kis["hldg"] == 5 diff --git a/backend/tests/test_swing_mdd_input_guard_r2_20261009.py b/backend/tests/test_swing_mdd_input_guard_r2_20261009.py new file mode 100644 index 0000000..f514312 --- /dev/null +++ b/backend/tests/test_swing_mdd_input_guard_r2_20261009.py @@ -0,0 +1,240 @@ +"""하드닝 R2 — MDD 갱신 입력 가드 회귀 테스트 (swing/scheduler._mdd_update_guarded_v2). + +결함: tot_evlu_amt 원값을 그대로 mdd_manager.update() 에 넣어 + - 출금 10%+ 한 번으로 drawdown<=-10% → is_paused + 전 보유 30% 시장가 축소, + - KIS 가 일부 종목 평가를 0 으로 준 응답 한 번으로 동일 축소, + - 입금이 peak 를 부풀리거나 진행 중인 MDD 정지를 해제했다. + +실제 MDDManager 상태 전이(_update_locked_v2)를 쓰고, 외부 I/O(Redis 저장·텔레그램)만 +예외 발생형/무동작 목으로 막는다. 임계값은 settings 를 참조한다. +""" +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from app.core.config import settings + + +class _NoIO(BaseException): + """Redis/텔레그램 실호출이 새면 즉시 터지게 하는 예외.""" + + +@pytest.fixture +def env(monkeypatch): + import app.services.swing.scheduler as sch + import app.services.telegram_notifier as tn + from app.services.mdd_manager import MDDManager + + mgr = MDDManager() + + async def _no_write(key, state): # Redis 쓰기 차단(무동작) — 상태 전이는 실제 로직 + return None + + monkeypatch.setattr(mgr, "_write_state_v2", _no_write) + monkeypatch.setattr(mgr, "save_to_redis", AsyncMock()) + monkeypatch.setattr(sch, "mdd_manager", mgr) + monkeypatch.setattr(sch, "_mdd_gate_state", {"prev": None, "pending": 0, "suspect": 0, "last_flow": None}) + monkeypatch.setattr(sch, "_alert_sent_at", {}) + monkeypatch.setattr(tn.telegram_notifier, "notify_mdd_warning", AsyncMock()) + monkeypatch.setattr(tn.telegram_notifier, "notify_system_warning", AsyncMock()) + monkeypatch.setattr(sch, "_send_system_warning_checked_v2", AsyncMock(return_value=True)) + return sch, mgr + + +def _resp(total, scts, holdings=None): + return { + "output1": holdings if holdings is not None else [], + "output2": [{"tot_evlu_amt": str(total), "scts_evlu_amt": str(scts)}], + } + + +async def _feed(sch, total, scts, holdings=None): + return await sch._mdd_update_guarded_v2(float(total), _resp(total, scts, holdings)) + + +def _pause_ratio(): + # [R3] 확인 대상 판정은 mdd_manager 실효 임계(_mdd_stage_thresholds_v2)와 같다 + import app.services.swing.scheduler as sch + return 1 + sch._mdd_stage_thresholds_v2()[0] / 100.0 + + +# ── 1. 출금은 낙폭이 아니다 ────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_withdrawal_does_not_trigger_mdd_reduction(env): + sch, mgr = env + await _feed(sch, 10_000_000, 6_000_000) # peak 10M + assert mgr.peak_balance == 10_000_000 + # 현금 4M 중 1.5M 출금 (총자산 -15%), 유가평가 불변 + r = await _feed(sch, 8_500_000, 6_000_000) + assert r["status"] == "applied" + assert r["result"]["action"] == "NONE" + assert mgr.reduction_applied is None and not mgr.is_paused + assert mgr.peak_balance == pytest.approx(8_500_000) + assert sch._mdd_gate_state["last_flow"] == pytest.approx(-1_500_000) + + +@pytest.mark.asyncio +async def test_withdrawal_preserves_existing_drawdown_pct(env): + sch, mgr = env + await _feed(sch, 10_000_000, 6_000_000) + await _feed(sch, 9_500_000, 5_500_000) # 실제 -5% (유가 손실) + await _feed(sch, 4_500_000, 5_500_000) # 현금 5M 출금 + dd = (4_500_000 - mgr.peak_balance) / mgr.peak_balance * 100 + assert dd == pytest.approx(-5.0, abs=1e-6) + assert mgr.reduction_applied is None + + +# ── 2. 입금이 진행 중인 MDD 정지를 해제하지 않는다 ───────────────────────────── + +@pytest.mark.asyncio +async def test_deposit_does_not_clear_active_pause(env): + sch, mgr = env + mgr.peak_balance = 10_000_000.0 + mgr.reduction_applied = "10pct" + mgr.is_paused = True + sch._mdd_gate_state["prev"] = {"total": 8_900_000.0, "scts": 6_000_000.0} + r = await _feed(sch, 11_900_000, 6_000_000) # 3M 입금 → 원값이면 peak 초과 + assert r["status"] == "applied" + assert mgr.reduction_applied == "10pct" and mgr.is_paused + assert mgr.peak_balance == pytest.approx(10_000_000 * 11_900_000 / 8_900_000) + + +# ── 3. 매매(현금↔유가 동반 이동)는 입출금으로 보지 않는다 ───────────────────── + +@pytest.mark.asyncio +async def test_buy_trade_not_treated_as_cash_flow(env): + sch, mgr = env + await _feed(sch, 10_000_000, 6_000_000) + await _feed(sch, 9_998_000, 6_598_000) # 60만원 매수(수수료 2천) + assert sch._mdd_gate_state["last_flow"] is None + assert mgr.peak_balance == 10_000_000 + + +@pytest.mark.asyncio +async def test_real_securities_loss_not_hidden_as_cash_flow(env): + """유가평가 급락은 입출금으로 숨기지 않고 확인 후 축소한다(안전 방향).""" + sch, mgr = env + await _feed(sch, 10_000_000, 6_000_000) + r1 = await _feed(sch, 8_800_000, 4_800_000) # 유가 -1.2M → -12% + assert sch._mdd_gate_state["last_flow"] is None + assert r1["status"] == "pending" + assert mgr.reduction_applied is None # 1회 관측으로는 전이하지 않음 + r2 = await _feed(sch, 8_800_000, 4_800_000) + assert r2["status"] == "applied" + assert r2["result"]["action"] == "REDUCE_AND_PAUSE" + assert mgr.reduction_applied == "10pct" and mgr.is_paused + + +# ── 4. 단일 사이클 급락은 확인 전까지 반영하지 않는다 ───────────────────────── + +@pytest.mark.asyncio +async def test_single_cycle_blip_is_not_applied(env): + sch, mgr = env + await _feed(sch, 10_000_000, 6_000_000) + low = 10_000_000 * _pause_ratio() - 100_000 + r = await _feed(sch, low, low - 4_000_000) + assert r["status"] == "pending" + r = await _feed(sch, 9_900_000, 5_900_000) # 다음 사이클 정상 복귀 + assert r["status"] == "applied" + assert sch._mdd_gate_state["pending"] == 0 + assert mgr.reduction_applied is None and not mgr.is_paused + + +@pytest.mark.asyncio +async def test_confirm_cycles_setting_respected(env, monkeypatch): + sch, mgr = env + monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_CONFIRM_CYCLES", 3) # config 키 미편입 — 기본값 경로 + await _feed(sch, 10_000_000, 6_000_000) + for _ in range(2): + assert (await _feed(sch, 8_500_000, 4_500_000))["status"] == "pending" + assert (await _feed(sch, 8_500_000, 4_500_000))["status"] == "applied" + assert mgr.reduction_applied == "10pct" + + +# ── 5. 부분응답(보유 평가 0) 교차검증 ───────────────────────────────────────── + +@pytest.mark.asyncio +async def test_partial_response_raises_and_keeps_state(env): + sch, mgr = env + held = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "6000000", "prpr": "600000"}] + await _feed(sch, 10_000_000, 6_000_000, held) + broken = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "0", "prpr": "0"}] + with pytest.raises(ValueError, match="부분응답"): + await _feed(sch, 4_000_000, 0, broken) + assert mgr.peak_balance == 10_000_000 + assert mgr.reduction_applied is None + assert sch._mdd_gate_state["prev"]["total"] == 10_000_000 # 부분응답은 기준값으로 쓰지 않음 + + +@pytest.mark.asyncio +async def test_persistent_partial_response_eventually_applied(env, monkeypatch): + """의심이 상한만큼 연속이면 실제 낙폭을 무기한 억제하지 않는다(§5.3).""" + sch, mgr = env + monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_SUSPECT_MAX_HOLDS", 2) + monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_CONFIRM_CYCLES", 1) # config 키 미편입 — 기본값 경로 + mgr.peak_balance = 10_000_000.0 + broken = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "0", "prpr": "0"}] + with pytest.raises(ValueError): + await _feed(sch, 4_000_000, 0, broken) + r = await _feed(sch, 4_000_000, 0, broken) + assert r["status"] == "applied" + assert mgr.reduction_applied == "20pct" + + +@pytest.mark.asyncio +async def test_missing_eval_fields_do_not_block_real_mdd(env, monkeypatch): + """필드가 없으면 의심으로 보지 않는다 — 필드명 불일치로 실제 MDD 를 막지 않게.""" + sch, mgr = env + monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_CONFIRM_CYCLES", 1) # config 키 미편입 — 기본값 경로 + mgr.peak_balance = 10_000_000.0 + held = [{"pdno": "005930", "hldg_qty": "10"}] + r = await _feed(sch, 8_500_000, 4_500_000, held) + assert r["status"] == "applied" + assert mgr.reduction_applied == "10pct" + + +@pytest.mark.asyncio +async def test_eval_sum_mismatch_is_suspect(env): + sch, mgr = env + mgr.peak_balance = 10_000_000.0 + held = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "6000000", "prpr": "600000"}] + with pytest.raises(ValueError, match="평가합"): + await _feed(sch, 8_000_000, 4_000_000, held) # scts 4M < 보유 평가합 6M + assert mgr.reduction_applied is None + + +# ── 6. 스케줄러 루프 배선 ──────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_loop_pending_blocks_entry_without_reduction(monkeypatch, env): + """확인 대기 사이클: update 미호출 + 축소 미실행 + 신규 진입 차단.""" + sch, mgr = env + from test_swing_scheduler_hardening_r1 import _loop_env, _one_cycle + resp = _resp(8_500_000, 4_500_000) + sch2, _api, calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=resp)) + # _loop_env 가 싱글톤 상태를 0 으로 고정하므로 그 뒤에 시나리오 상태를 세팅 + monkeypatch.setattr(sch2.mdd_manager, "peak_balance", 10_000_000.0) + monkeypatch.setattr(sch2, "_mdd_gate_state", { + "prev": {"total": 10_000_000.0, "scts": 6_000_000.0}, "pending": 0, "suspect": 0, "last_flow": None, + }) + reduce = AsyncMock(side_effect=AssertionError("확인 전 축소 금지")) + monkeypatch.setattr(sch2.mdd_manager, "execute_reduction", reduce) + await _one_cycle(sch2) + sch2.mdd_manager.update.assert_not_awaited() + reduce.assert_not_awaited() + assert calls["entry"] == 0 + assert sch2._mdd_gate_state["pending"] == 1 + + +@pytest.mark.asyncio +async def test_loop_partial_response_counts_as_update_failure(monkeypatch, env): + sch, mgr = env + from test_swing_scheduler_hardening_r1 import _loop_env, _one_cycle + broken = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "0", "prpr": "0"}] + sch2, _api, _calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=_resp(4_000_000, 0, broken))) + monkeypatch.setattr(sch2.mdd_manager, "peak_balance", 10_000_000.0) + await _one_cycle(sch2) + sch2.mdd_manager.record_update_failure.assert_awaited_once() + sch2.mdd_manager.update.assert_not_awaited() diff --git a/backend/tests/test_swing_partial_exit_no_active_trade.py b/backend/tests/test_swing_partial_exit_no_active_trade.py index 095333f..51034bf 100644 --- a/backend/tests/test_swing_partial_exit_no_active_trade.py +++ b/backend/tests/test_swing_partial_exit_no_active_trade.py @@ -20,6 +20,23 @@ from app.services import db_worker from app.services.swing import exit_service +class _DBBlocked(RuntimeError): + """테스트 중 운영 DB 세션 생성 시도. 연결 실패에 우연히 의존하지 않도록 즉시 예외.""" + + +def _blocked_session_factory(*_a, **_k): + raise _DBBlocked("테스트에서 app.core.database.AsyncSessionLocal 직접 사용 차단") + + +@pytest.fixture(autouse=True) +def _block_real_db_session(monkeypatch): + """portfolio_cache_bridge._db_quantity 는 함수 내부에서 app.core.database.AsyncSessionLocal 을 + import 하므로 127.0.0.1:5432 실연결을 시도한다. 호출 즉시 예외로 치환한다 + (_db_quantity 는 Exception 을 삼켜 None 반환 = 기존 기대 동작과 동일, 실소켓은 없음).""" + import app.core.database as database + monkeypatch.setattr(database, "AsyncSessionLocal", _blocked_session_factory) + + def _daily_chart(n=30, close=86200.0): return {"output2": [ {"stck_clpr": str(close), "stck_hgpr": str(close * 1.01), "stck_lwpr": str(close * 0.99)} @@ -77,7 +94,8 @@ async def test_partial_exit_without_active_trade_runs_once(): patch("app.services.trade_executor.safe_place_order", place_order), \ patch.object(db_worker, "db_task_queue", queue), \ patch.object(exit_service.loss_tracker, "record_trade_result_safe", AsyncMock()), \ - patch.object(exit_service.loss_tracker, "save_to_db", AsyncMock()): + patch.object(exit_service.loss_tracker, "save_to_db", AsyncMock()), \ + patch.object(exit_service, "_schedule_swing_sell_fill_check_v2"): # [2026-10-08] 체결 확인 백그라운드(KIS 조회) 차단 # 1차 사이클: 18주 → 9주 매도 async with Session() as s: @@ -124,3 +142,10 @@ async def test_db_worker_skips_active_trade_insert_without_required_keys(): rows = (await s.execute(select(ActiveTrade))).scalars().all() assert rows == [] await engine.dispose() + + +def test_real_db_session_is_blocked_in_this_module(): + """회귀: 이 모듈 테스트는 실 DB 세션을 만들 수 없어야 한다(예외 발생형 차단).""" + from app.core import database + with pytest.raises(_DBBlocked): + database.AsyncSessionLocal() diff --git a/backend/tests/test_swing_pm_hardening_r1_20261009.py b/backend/tests/test_swing_pm_hardening_r1_20261009.py new file mode 100644 index 0000000..9bfd9ae --- /dev/null +++ b/backend/tests/test_swing_pm_hardening_r1_20261009.py @@ -0,0 +1,218 @@ +"""[2026-10-09 하드닝 R1] swing/position_manager 회귀 테스트. + +1. 진입가 괴리 > 한도 → 유령 고가 entry 로 허위 전량 손절하지 않는다(KIS 평단 사용). +2. Portfolio 태그 daytrading + 비-DT active ActiveTrade → 관리 주체 없는 수량 경보(105560). +3. 스윙 SELL 출처(system/inflow) 분리 — 동기화 유입분이 전략 성과에 섞이지 않게. +""" +import asyncio +import logging +from datetime import datetime, timedelta +from types import SimpleNamespace + +import pytest + +from app.core.config import settings +from app.services.swing import position_manager as pm + + +def _run(coro): + return asyncio.run(coro) + + +# ── 1. 진입가 해석 ────────────────────────────────────────────────────────── + +def test_ghost_high_entry_does_not_trigger_false_stop(): + """유령 entry 100,000 / 실평단 50,000 / 현재가 52,000 → 이전엔 -48% 전량 손절.""" + price, basis = pm.resolve_swing_entry_price_v2(100_000, 50_000, "TEST01") + assert (price, basis) == (50_000.0, "portfolio_avg_deviation") + pnl_pct = (52_000 / price - 1.0) * 100 + hard_floor = -abs(float(settings.STOP_LOSS_PERCENT)) + assert pnl_pct > hard_floor, "실제로는 +4% — 손절 판정되면 안 된다" + + +def test_ghost_low_entry_does_not_delay_stop(): + """반대 방향: 유령 entry 가 낮아도 KIS 평단 기준이므로 손절이 늦춰지지 않는다.""" + pa = 50_000.0 + price, basis = pm.resolve_swing_entry_price_v2(pa * 0.5, pa, "TEST02") + assert (price, basis) == (pa, "portfolio_avg_deviation") + hard_floor = -abs(float(settings.STOP_LOSS_PERCENT)) + cur = pa * (1 + (hard_floor - 1.0) / 100) # 하드 바닥보다 1%p 더 하락 + assert (cur / price - 1.0) * 100 <= hard_floor + + +def test_within_band_still_uses_active_trade(): + pa = 10_000.0 + at = pa * (1 + pm.SWING_ENTRY_PRICE_MAX_DEVIATION * 0.5) + assert pm.resolve_swing_entry_price_v2(at, pa) == (pytest.approx(at), "active_trade") + + +# ── 2. 태그 불일치 탐지 ──────────────────────────────────────────────────── + +def _p(code, qty, st): + return SimpleNamespace(stock_code=code, quantity=qty, strategy_type=st) + + +def _a(code, name, id_=1): + return SimpleNamespace(stock_code=code, strategy_name=name, id=id_, status="active") + + +def test_105560_dt_tag_with_synced_active_is_flagged(): + found = pm.find_strategy_tag_mismatches_v2( + [_p("105560", 3, "daytrading")], [_a("105560", "synced", 178)], {}, + ) + assert len(found) == 1 + m = found[0] + assert m["stock_code"] == "105560" and m["unmanaged_qty"] == 3 + assert m["active_trade_ids"] == [178] + + +def test_dt_inmemory_covering_qty_is_not_flagged(): + assert pm.find_strategy_tag_mismatches_v2( + [_p("105560", 3, "daytrading")], [_a("105560", "synced")], {"105560": 3}, + ) == [] + # 부분 커버 → 잔여만 경보 + found = pm.find_strategy_tag_mismatches_v2( + [_p("105560", 6, "daytrading")], [_a("105560", "synced")], {"105560": 3}, + ) + assert found[0]["unmanaged_qty"] == 3 + + +@pytest.mark.parametrize("portfolio,active", [ + ([_p("105560", 3, "daytrading")], [_a("105560", "daytrading")]), # DT 행뿐 + ([_p("105560", 3, "daytrading")], [_a("105560", "day_trade")]), + ([_p("105560", 3, "swing")], [_a("105560", "synced")]), # 스윙 태그는 스윙 청산이 봄 + ([_p("105560", 0, "daytrading")], [_a("105560", "synced")]), # 수량 0 + ([_p("105560", 3, "daytrading")], []), +]) +def test_non_mismatch_cases(portfolio, active): + assert pm.find_strategy_tag_mismatches_v2(portfolio, active, {}) == [] + + +def test_null_strategy_name_counts_as_non_dt(): + found = pm.find_strategy_tag_mismatches_v2( + [_p("105560", 3, "day_trade")], [_a("105560", None)], {}, + ) + assert found and found[0]["unmanaged_qty"] == 3 + + +class _Res: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return self + + def all(self): + return list(self._rows) + + +class _FakeDB: + """execute 응답을 순서대로 반환. 예외 항목은 raise. 쓰기 메서드는 예외.""" + + def __init__(self, responses): + self._responses = list(responses) + self.calls = 0 + + async def execute(self, _stmt): + self.calls += 1 + r = self._responses.pop(0) + if isinstance(r, BaseException): + raise r + return _Res(r) + + def add(self, *_a, **_k): # pragma: no cover + raise AssertionError("position_manager 는 쓰기 금지") + + async def commit(self): # pragma: no cover + raise AssertionError("position_manager 는 commit 금지") + + +@pytest.fixture +def _fresh_throttle(monkeypatch): + from app.core import log_throttle + monkeypatch.setattr(log_throttle, "_state", {}) + + +@pytest.fixture +def _no_dt_positions(monkeypatch): + from app.services.daytrading.position_manager import day_trading_state + monkeypatch.setattr(day_trading_state, "positions", {}) + + +def test_detect_logs_error_for_unmanaged(caplog, _fresh_throttle, _no_dt_positions): + db = _FakeDB([[_p("105560", 3, "daytrading")], [_a("105560", "synced", 178)]]) + with caplog.at_level(logging.ERROR, logger=pm.logger.name): + found = _run(pm.detect_strategy_tag_mismatch_v2(db)) + assert [m["stock_code"] for m in found] == ["105560"] + assert any("105560" in r.message and "손절 없음" in r.message for r in caplog.records) + + +def test_detect_skips_active_query_when_no_dt_tagged_rows(_no_dt_positions): + db = _FakeDB([[]]) + assert _run(pm.detect_strategy_tag_mismatch_v2(db)) == [] + assert db.calls == 1 + + +def test_get_swing_holdings_survives_detection_failure(caplog, _no_dt_positions): + """경보 실패가 청산 대상 조회를 막으면 안 된다(청산 우선).""" + holding = _p("005930", 5, "swing") + db = _FakeDB([[holding], RuntimeError("db down")]) + with caplog.at_level(logging.WARNING, logger=pm.logger.name): + out = _run(pm.get_swing_holdings(db)) + assert list(out) == [holding] + assert any("탐지 실패" in r.message and r.levelno >= logging.WARNING for r in caplog.records) + + +def test_dt_snapshot_failure_falls_back_to_alerting(monkeypatch): + from app.services.daytrading.position_manager import day_trading_state + + class _Boom: + def keys(self): + raise RuntimeError("boom") + + def items(self): + raise RuntimeError("boom") + + monkeypatch.setattr(day_trading_state, "positions", _Boom()) + assert pm._dt_inmemory_qty_snapshot_v2() == {} + + +# ── 3. 스윙 SELL 출처 분리 ────────────────────────────────────────────────── + +def _t(code, side, qty, minutes, st="swing", status="COMPLETED", id_=None): + return { + "id": id_, "stock_code": code, "order_type": side, "executed_quantity": qty, + "created_at": datetime(2026, 10, 1, 9, 0) + timedelta(minutes=minutes), + "strategy_type": st, "status": status, + } + + +def test_sell_without_prior_swing_buy_is_inflow(): + out = pm.split_swing_sells_by_origin_v2([ + _t("022100", "SELL", 10, 0, id_=1), + _t("022100", "BUY", 5, 10), + _t("022100", "SELL", 5, 20, id_=3), + ]) + assert [(o["id"], o["origin"], o["system_qty"], o["inflow_qty"]) for o in out] == [ + (1, "inflow", 0, 10), (3, "system", 5, 0), + ] + + +def test_sell_exceeding_system_holding_is_mixed(): + out = pm.split_swing_sells_by_origin_v2([ + _t("009420", "BUY", 3, 0), + _t("009420", "SELL", 8, 5, id_=2), + ]) + assert out[0]["origin"] == "mixed" + assert (out[0]["system_qty"], out[0]["inflow_qty"]) == (3, 5) + + +def test_unsorted_input_and_ignored_rows(): + out = pm.split_swing_sells_by_origin_v2([ + _t("323410", "SELL", 4, 30, id_=9), + _t("323410", "BUY", 4, 0, st="daytrading"), # DT 매수는 스윙 보유 아님 + _t("323410", "BUY", 4, 10, status="FAILED"), # 실패 매수 무시 + _t("323410", "BUY", 2, 20), + ]) + assert len(out) == 1 + assert (out[0]["system_qty"], out[0]["inflow_qty"], out[0]["origin"]) == (2, 2, "mixed") diff --git a/backend/tests/test_swing_r3_fixes_20261002.py b/backend/tests/test_swing_r3_fixes_20261002.py index 394bbc2..87719ac 100644 --- a/backend/tests/test_swing_r3_fixes_20261002.py +++ b/backend/tests/test_swing_r3_fixes_20261002.py @@ -606,10 +606,19 @@ def dual_env(monkeypatch): monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=False, trading_mode="normal")) monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0)) - bars = [{"stck_clpr": str(100 + i), "stck_hgpr": str(101 + i), "acml_vol": "1000"} + # [하드닝 R2 2026-10-09] 차트 현재 종가에도 가격 필터(MIN_PRICE_KRW 이하 거부)가 걸리므로 + # 가격대를 settings 기준으로 경계 위에 둔다. + _base = int(settings.MIN_PRICE_KRW) * 10 + bars = [{"stck_clpr": str(_base + i), "stck_hgpr": str(_base + 1 + i), "acml_vol": "1000"} for i in range(30)] api = MagicMock() api.get_daily_chart = AsyncMock(return_value={"output2": bars}) + # [하드닝 R2 2026-10-09] 듀얼 스캔은 네이버 → KIS 기간 조회 → get_daily_chart 순으로 + # 일봉을 확보한다. 네이버는 conftest 전역 차단 대상이 아니므로 반드시 목킹한다. + import app.services.naver_finance as nf + monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=None)) + api._quote_delegate = lambda: None + api.get_daily_ohlcv_range = AsyncMock(return_value=[]) monkeypatch.setattr(kia, "korea_investment_api", api) monkeypatch.setattr(mr, "check_momentum_breakout_signal_v2", lambda **kw: { "signal": "BUY", "strategy": "MOMENTUM_BREAKOUT", "confidence": 0.7, "description": "t"}) @@ -733,6 +742,22 @@ def _scheduler_env(monkeypatch, mr_side_effect=None): monkeypatch.setattr(rec_mod, "get_rule_based_recommendations_from_db", AsyncMock(return_value={})) monkeypatch.setattr(mcp_mod, "request_mcp_risk_assessment", AsyncMock(return_value=(True, ""))) monkeypatch.setattr(redis_mod, "get_json", AsyncMock(return_value=None)) + # [하드닝 R2] 기동 시 _check_mdd_threshold_knobs_v2 → strategy_cfg.aget → _refresh_impl → + # AsyncSessionLocal() 이 실제 DB(127.0.0.1:5432)에 접속하고 있었다(소켓 차단 하 실패, + # 로컬 DB 가 떠 있으면 실제 strategy_configs 행에 의존). 예외 발생형으로 막는다: + # - strategy_cfg.aget: RuntimeError → 대조 함수의 "DB 조회 실패(대조 생략)" 분기를 실제로 탄다 + # - strategy_config_service.AsyncSessionLocal(공통 종단): 호출되면 기록 + 예외 → 테스트가 0회를 단언 + import app.services.strategy_config_service as scs + sessions_opened = [] + + def _no_db_session(*a, **k): + sessions_opened.append(1) + raise RuntimeError("unit test: strategy_config DB session blocked") + + monkeypatch.setattr(scs, "AsyncSessionLocal", _no_db_session) + aget = AsyncMock(side_effect=RuntimeError("unit test: strategy_cfg.aget blocked")) + monkeypatch.setattr(scs.strategy_cfg, "aget", aget) + order_meta = {"sessions_opened": sessions_opened, "aget": aget} async def _mr(*a, **k): order.append("mean_reversion") @@ -744,6 +769,7 @@ def _scheduler_env(monkeypatch, mr_side_effect=None): monkeypatch.setattr(sch, "_run_recommendation_entry_v2", _mr) monkeypatch.setattr(sch, "_run_dual_strategy_scan_v2", _dual) + monkeypatch.setattr(sch, "_r2_test_meta", order_meta, raising=False) # 회귀 테스트 단언용 return sch, order @@ -759,6 +785,25 @@ async def test_scheduler_runs_mean_reversion_then_dual(monkeypatch): assert order == ["mean_reversion", "dual"] +@pytest.mark.asyncio +async def test_scheduler_startup_mdd_knob_check_opens_no_db_session(monkeypatch): + """[하드닝 R2] 기동 대조(_check_mdd_threshold_knobs_v2)는 실제로 호출되지만(aget 첫 키에서 예외), + strategy_config DB 세션은 한 번도 열지 않고, 조회 실패가 사이클 진행을 막지 않는다.""" + sch, order = _scheduler_env(monkeypatch) + meta = sch._r2_test_meta + monkeypatch.setattr(sch, "_mdd_knob_state", {"mismatches": ["stale"]}) + await _one_cycle(sch) + assert order == ["mean_reversion", "dual"] + keys = [c.args[0] for c in meta["aget"].await_args_list] + assert keys == ["MDD_PAUSE_THRESHOLD"] # 첫 조회 예외 → 대조 생략(warning), DB 미접속 + assert meta["sessions_opened"] == [] + # settings 값만으로 대조가 끝났다 — settings 가 실효값과 같으면 불일치 없음 + expected = sch._mdd_threshold_mismatches_v2({"settings": { + "MDD_PAUSE_THRESHOLD": settings.MDD_PAUSE_THRESHOLD, + "MDD_FORCE_THRESHOLD": settings.MDD_FORCE_THRESHOLD}}) + assert sch._mdd_knob_state["mismatches"] == expected + + @pytest.mark.asyncio async def test_scheduler_mean_reversion_error_does_not_skip_dual(monkeypatch): sch, order = _scheduler_env(monkeypatch, mr_side_effect=RuntimeError("boom")) diff --git a/backend/tests/test_swing_r5_pending_sell_20261002.py b/backend/tests/test_swing_r5_pending_sell_20261002.py index 0e41dde..cb498d9 100644 --- a/backend/tests/test_swing_r5_pending_sell_20261002.py +++ b/backend/tests/test_swing_r5_pending_sell_20261002.py @@ -26,6 +26,15 @@ UNKNOWN = {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeou OK = {"rt_cd": "0", "output": {"ODNO": "9"}} +_FILL_CHECKS: list = [] + + +async def _record_fill_check(order_id, stock_code, stock_name, quantity, price, side, *args, **kwargs): + """체결 확인 종단 기록형 목 — 즉시 완료(대기·KIS 조회 없음).""" + _FILL_CHECKS.append({"order_id": order_id, "stock_code": stock_code, "quantity": quantity, + "side": side, "kwargs": kwargs}) + + @pytest.fixture(autouse=True) def _reset(monkeypatch): import app.services.swing.exit_service as es @@ -41,6 +50,13 @@ def _reset(monkeypatch): monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None) import app.services.trade_executor as te monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) + # [하드닝 R2 2026-10-09] 성공한 시장가 매도는 _schedule_swing_sell_fill_check_v2 → + # spawn_tracked_task_v2(_verify_and_sync_execution_v3(..., delay=3)) 로 **실제** 체결 확인 + # 코루틴을 띄운다. 목이 없으면 테스트 종료 후 pending 으로 남고(“Task was destroyed but it is + # pending”), 3초가 지나면 get_order_execution(KIS)·enqueue_update_trade_history 를 호출해 + # conftest 차단에만 의존했다(§4.1 위반). 공통 종단(_verify_and_sync_execution_v3)에 기록형 목. + _FILL_CHECKS.clear() + monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _record_fill_check) class _Sim: @@ -338,3 +354,121 @@ async def test_m4_cancel_during_order_still_records(monkeypatch): break await asyncio.sleep(0) assert len(sim.history) == 1 and sim.closed == 1 + + +# ── [2026-10-09 R1] 연속손실 판정 = 비용 차감 순손익 (CLAUDE.md §5.1) ───────── + +def _full_signal(remaining): + return ExitSignal("SELL_ALL", "전량 익절", 1.0) + + +def _expected_net(avg, price, qty): + from app.services.trade_helpers import calculate_trade_costs + costs = calculate_trade_costs(entry_price=avg, exit_price=price, quantity=qty, + is_domestic=True, market="KOSPI") + return (price - avg) * qty - costs["total_cost"], costs["total_cost"] + + +def test_r1_swing_net_pnl_matches_trade_helpers_and_flips_small_gain(): + """+0.1% 는 총손익 양수지만 왕복 비용(수수료×2+매도세) 차감 후 음수. 비용 정의는 + trade_helpers.calculate_trade_costs 와 동일(KOSPI/KOSDAQ 세율 모두).""" + import app.services.swing.exit_service as es + from app.services.trade_helpers import calculate_trade_costs + for mkt, label in (("KRX", "KOSPI"), ("KOSDAQ", "KOSDAQ")): + r = es._swing_net_pnl_v2(10000, 10010, 10, mkt, domestic=True) + c = calculate_trade_costs(entry_price=10000, exit_price=10010, quantity=10, + is_domestic=True, market=label) + assert r["gross_profit_loss"] == 100 + assert r["total_cost"] == c["total_cost"] + assert r["net_profit_loss"] == 100 - c["total_cost"] + assert r["net_profit_loss"] < 0 + # 해외는 거래세 없음(trade_helpers 와 동일) + r = es._swing_net_pnl_v2(100, 101, 10, "NASD", domestic=False) + c = calculate_trade_costs(entry_price=100, exit_price=101, quantity=10, is_domestic=False) + assert r["total_cost"] == c["total_cost"] + + +@pytest.mark.asyncio +async def test_r1_full_exit_within_cost_band_counts_as_loss(monkeypatch): + """[재현 반전] 스윙 10주 +0.1% 전량 청산. 이전: is_loss=False(총손익 +100원), net_profit_loss + 에 총손익 기록. 이제: 순손익 기준 is_loss=True, net_profit_loss·commission 은 비용 차감값.""" + avg, price = 10000, 10010 + sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal) + await sim.cycles(1) + assert sim.sent == [10] + net, cost = _expected_net(avg, price, 10) + assert net < 0 < (price - avg) * 10 + sim.loss.assert_awaited_once() + assert sim.loss.await_args.kwargs["is_loss"] is True + sell = [h for h in sim.history if h["order_type"] == "SELL"] + assert len(sell) == 1 + assert sell[0]["net_profit_loss"] == net + assert sell[0]["commission"] == cost + + +@pytest.mark.asyncio +async def test_r1_full_exit_clear_gain_is_not_loss(monkeypatch): + """비용을 넘는 이익(+5%)은 여전히 승 — 판정이 과도하게 뒤집히지 않음.""" + avg, price = 10000, 10500 + sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal) + await sim.cycles(1) + sim.loss.assert_awaited_once() + assert sim.loss.await_args.kwargs["is_loss"] is False + net, _ = _expected_net(avg, price, 10) + assert [h for h in sim.history if h["order_type"] == "SELL"][0]["net_profit_loss"] == net + + +@pytest.mark.asyncio +async def test_r1_unknown_sell_reconcile_uses_net_pnl(monkeypatch): + """결과 불명 전량 매도가 KIS 잔고로 체결 확인되는 경로도 순손익 기준으로 판정·기록.""" + avg, price = 10000, 10010 + sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal, + responses=[UNKNOWN]) + await sim.cycles(3) + assert sum(sim.sent) == 10 + sim.loss.assert_awaited_once() + assert sim.loss.await_args.kwargs["is_loss"] is True + done = [h for h in sim.history if "체결 확인" in (h.get("exit_reason") or "")] + assert len(done) == 1 + net, cost = _expected_net(avg, price, 10) + assert done[0]["net_profit_loss"] == net and done[0]["commission"] == cost + + +# ── 하드닝 R2 (2026-10-09): 체결 확인 백그라운드 태스크가 테스트 밖으로 새지 않는다 ───── + +@pytest.mark.asyncio +async def test_r2_fill_check_terminal_is_mocked_and_task_completes(monkeypatch): + """성공 매도 → 체결 확인 예약은 기록형 목 종단으로 수렴하고, swing_sell_fill 태스크가 + 테스트 종료 전에 끝난다(실 _verify_and_sync_execution_v3 미실행 → KIS 조회 0).""" + import app.services.trade_executor as te + assert te._verify_and_sync_execution_v3 is _record_fill_check + sim = _Sim(monkeypatch, swing=10, dt=0) + await sim.cycles(1) + assert sim.sent == [10] and sim.closed == 1 + assert len(_FILL_CHECKS) == 1 + fc = _FILL_CHECKS[0] + assert fc["stock_code"] == "005930" and fc["quantity"] == 10 and fc["side"] == "SELL" + assert fc["kwargs"]["sell_pnl_basis"]["strategy_type"] == "swing" + # 기록형 목은 즉시 완료 — 한 틱 양보 후 남은 swing_sell_fill 태스크가 없어야 한다 + await asyncio.sleep(0) + await asyncio.sleep(0) + pending = [t for t in asyncio.all_tasks() + if (t.get_name() or "").startswith("swing_sell_fill:") and not t.done()] + assert pending == [] + + +@pytest.mark.asyncio +async def test_r2_fill_check_real_coroutine_would_hit_kis_is_not_reached(monkeypatch): + """종단 목이 빠지면 실 코루틴이 get_order_execution 을 부른다는 전제를 예외 발생형 목으로 + 고정 — 이 파일의 매도 경로가 그 메서드에 도달하지 않음을 증명한다.""" + sim = _Sim(monkeypatch, swing=10, dt=0) + + async def _boom(*a, **k): + raise AssertionError("get_order_execution 실호출 경로 도달") + sim.api.get_order_execution = AsyncMock(side_effect=_boom) + import app.services.trade_executor as te + monkeypatch.setattr(te, "korea_investment_api", sim.api, raising=False) + await sim.cycles(1) + await asyncio.sleep(0) + sim.api.get_order_execution.assert_not_called() + assert len(_FILL_CHECKS) == 1 diff --git a/backend/tests/test_swing_scheduler_hardening_r1.py b/backend/tests/test_swing_scheduler_hardening_r1.py new file mode 100644 index 0000000..3bb6b7b --- /dev/null +++ b/backend/tests/test_swing_scheduler_hardening_r1.py @@ -0,0 +1,623 @@ +"""하드닝 R1 — 스윙 스케줄러 단계 상한·청산 판정 하트비트·텔레그램 경보 회귀 테스트. + +결함: + 1. 장중 청산 판정이 N분간 완료되지 않아도 경보 없음 (2026-05-15~09-21 휴면 중 손절 방치) + 2. 스윙 루프 단계에 타임아웃 없음 → KIS 무응답 시 청산·MDD 루프 전체 정지 (DT 는 _guard 존재) + 3. MDD fail-safe 매수 차단·청산 체크 반복 실패가 로그로만 남음 (텔레그램 없음) + +목은 공통 종단(check_and_execute_exit_v2 / mdd_manager.execute_reduction / +korea_investment_api.get_balance / telegram_notifier.notify_system_warning)에 건다. +'멈춤'은 끝나지 않는 Event 대기로, 실패는 예외 발생형 목으로 재현한다. +""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.core.config import settings + +_real_sleep = asyncio.sleep + + +class _StopLoop(BaseException): + pass + + +class _SessionCM: + def __init__(self, db=None): + self.db = db if db is not None else MagicMock() + + async def __aenter__(self): + return self.db + + async def __aexit__(self, *a): + return False + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch): + import app.services.swing.scheduler as sch + import app.services.telegram_notifier as tn + + sch._exit_health.update({ + "last_success_at": None, "market_open_since": None, + "consecutive_failures": 0, "last_error": None, "timeouts": 0, + }) + for t in list(sch._inflight_steps.values()): + if not t.done(): + t.cancel() + sch._inflight_steps.clear() + sch._alert_sent_at.clear() + sch._step_timeouts.clear() + sch._blind_prev["active"] = False + sch._telegram_state["enabled"] = None + notify = AsyncMock(return_value=True) + # 실전송 차단: 예외 발생형(목킹 누락 시 즉시 터짐) + 전송 공통 종단은 성공 목 + monkeypatch.setattr(tn.telegram_notifier, "notify_system_warning", + AsyncMock(side_effect=AssertionError("R3 이후 경로 아님"))) + monkeypatch.setattr(tn.telegram_notifier, "send_message", + AsyncMock(side_effect=AssertionError("텔레그램 실전송 금지"))) + monkeypatch.setattr(sch, "_send_system_warning_checked_v2", notify) + monkeypatch.setattr(sch, "_inflight_meta", {}) + monkeypatch.setattr(sch, "_hard_deadline_stats", {"cancels": 0, "exceeded": {}}) + monkeypatch.setattr(sch, "_telegram_send_health", { + "sent": 0, "failures": 0, "consecutive_failures": 0, + "last_success_at": None, "last_failure_at": None, "last_error": None, + }) + monkeypatch.setattr(sch, "_mdd_knob_state", {"mismatches": []}) + yield SimpleNamespace(sch=sch, notify=notify) + for t in list(sch._inflight_steps.values()): + if not t.done(): + t.cancel() + sch._inflight_steps.clear() + + +async def _drain(): + for _ in range(5): + await _real_sleep(0) + + +def _messages(notify): + return [c.kwargs.get("message", "") for c in notify.await_args_list] + + +def _loop_env(monkeypatch, *, exit_mock=None, balance=None, can_open=True): + """swing_scheduler 1사이클 실행 환경. sleep(>=60) 에서 루프를 끊는다.""" + import app.services.swing.scheduler as sch + import app.services.korea_investment_api as kia + import app.api.endpoints.recommendations as rec_mod + import app.api.endpoints.mcp as mcp_mod + import app.core.redis as redis_mod + import app.services.swing.fill_sweep as fs + + calls = {"entry": 0} + + async def _sleep(sec): + if sec >= 60: + raise _StopLoop() + await _real_sleep(0) + + monkeypatch.setattr(sch.asyncio, "sleep", _sleep) + monkeypatch.setattr(sch, "is_market_open", lambda: True) + monkeypatch.setattr(sch, "check_and_execute_exit_v2", exit_mock or AsyncMock(return_value=[])) + monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM()) + monkeypatch.setattr(sch, "_check_mdd_threshold_knobs_v2", AsyncMock(return_value=[])) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(return_value=0)) + api = MagicMock() + api.get_balance = balance or AsyncMock(return_value={"output2": [{"tot_evlu_amt": "10000000"}]}) + monkeypatch.setattr(kia, "korea_investment_api", api) + monkeypatch.setattr(sch.mdd_manager, "update", AsyncMock(return_value={"action": "NONE"})) + # [R2] MDD 입력 가드가 실제 peak/단계를 읽는다 — 앞선 테스트의 싱글톤 상태가 새지 않게 고정 + monkeypatch.setattr(sch.mdd_manager, "peak_balance", 0.0) + monkeypatch.setattr(sch.mdd_manager, "reduction_applied", None) + monkeypatch.setattr(sch, "_mdd_gate_state", {"prev": None, "pending": 0, "suspect": 0, "last_flow": None}) + monkeypatch.setattr(sch.mdd_manager, "record_update_success", AsyncMock()) + monkeypatch.setattr(sch.mdd_manager, "record_update_failure", AsyncMock()) + monkeypatch.setattr(sch.mdd_manager, "can_open_new_position", AsyncMock(return_value=can_open)) + monkeypatch.setattr(sch.mdd_manager, "get_update_health", lambda: {"blind_block_active": False}) + monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", True) + monkeypatch.setattr(rec_mod, "get_rule_based_recommendations_from_db", AsyncMock(return_value={})) + monkeypatch.setattr(mcp_mod, "request_mcp_risk_assessment", AsyncMock(return_value=(True, ""))) + monkeypatch.setattr(redis_mod, "get_json", AsyncMock(return_value=None)) + + async def _entry(*a, **k): + calls["entry"] += 1 + + monkeypatch.setattr(sch, "_run_entry_phase_v2", _entry) + return sch, api, calls + + +async def _one_cycle(sch): + with pytest.raises(_StopLoop): + await sch.swing_scheduler() + + +# ═══════════════════════════════════════════════════════════════════════════ +# 결함 2 — 단계 상한 +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_hung_exit_check_does_not_freeze_loop_and_is_not_cancelled(monkeypatch, _reset_state): + """청산 판정이 멈춰도 루프는 MDD·진입 단계로 진행하고, 판정 작업은 취소하지 않는다.""" + started = asyncio.Event() + never = asyncio.Event() + + async def _hang(db): + started.set() + await never.wait() + + exit_mock = AsyncMock(side_effect=_hang) + sch, api, calls = _loop_env(monkeypatch, exit_mock=exit_mock) + monkeypatch.setattr(sch, "_DEFAULT_EXIT_STEP_TIMEOUT_SEC", 0.05) + + await _one_cycle(sch) + await _drain() + + assert started.is_set() + api.get_balance.assert_awaited() # MDD 단계 도달 + assert calls["entry"] == 1 # 진입 단계 도달 + task = sch._inflight_steps["청산 판정"] + assert not task.done() # 취소하지 않음(매도 이중 실행 방지) + assert sch._exit_health["timeouts"] == 1 + assert sch._step_timeouts["청산 판정"] == 1 + assert any("청산 판정" in m and "초과" in m for m in _messages(_reset_state.notify)) + + +@pytest.mark.asyncio +async def test_hung_exit_check_not_restarted_while_inflight(monkeypatch, _reset_state): + """상한 초과로 남은 청산 판정이 끝나기 전에는 새 판정을 시작하지 않는다(과다매도 방지).""" + never = asyncio.Event() + + async def _hang(db): + await never.wait() + + exit_mock = AsyncMock(side_effect=_hang) + sch, _api, _calls = _loop_env(monkeypatch, exit_mock=exit_mock) + monkeypatch.setattr(sch, "_DEFAULT_EXIT_STEP_TIMEOUT_SEC", 0.05) + + await _one_cycle(sch) + await _one_cycle(sch) + await _drain() + + assert exit_mock.await_count == 1 + assert sch._exit_health["consecutive_failures"] == 2 + never.set() + await _drain() + # 백그라운드 판정이 끝나면 스스로 성공을 기록한다 + assert sch._exit_health["consecutive_failures"] == 0 + assert sch._exit_health["last_success_at"] is not None + + +@pytest.mark.asyncio +async def test_hung_balance_counts_as_mdd_update_failure(monkeypatch, _reset_state): + """get_balance 무응답은 취소되고 MDD 갱신 실패로 계수된다(fail-safe 경로 유지).""" + never = asyncio.Event() + + async def _hang(): + await never.wait() + + sch, api, calls = _loop_env(monkeypatch, balance=AsyncMock(side_effect=_hang)) + monkeypatch.setattr(sch, "_DEFAULT_BALANCE_TIMEOUT_SEC", 0.05) + + await _one_cycle(sch) + + sch.mdd_manager.record_update_failure.assert_awaited_once() + sch.mdd_manager.update.assert_not_awaited() + assert calls["entry"] == 1 + + +@pytest.mark.asyncio +async def test_hung_mdd_reduction_is_detached_and_not_duplicated(monkeypatch, _reset_state): + """MDD 축소가 멈추면 취소하지 않고 남겨 두며, 끝날 때까지 새 축소를 시작하지 않는다.""" + never = asyncio.Event() + + async def _hang(action): + await never.wait() + + sch, _api, calls = _loop_env(monkeypatch, can_open=False) + reduce_mock = AsyncMock(side_effect=_hang) + monkeypatch.setattr(sch.mdd_manager, "execute_reduction", reduce_mock) + monkeypatch.setattr(sch.mdd_manager, "reduction_applied", "10pct") + monkeypatch.setattr(sch, "_DEFAULT_MDD_REDUCTION_TIMEOUT_SEC", 0.05) + + await _one_cycle(sch) + await _one_cycle(sch) + await _drain() + + assert reduce_mock.await_count == 1 + assert not sch._inflight_steps["MDD 축소"].done() + assert calls["entry"] == 0 # MDD 차단 중 진입 없음 + msgs = _messages(_reset_state.notify) + assert any("MDD 축소" in m and "초과" in m for m in msgs) + assert any("MDD 축소" in m and "진행 중" in m for m in msgs) + + +@pytest.mark.asyncio +async def test_hung_entry_phase_lets_next_cycle_run_exit(monkeypatch, _reset_state): + """진입 단계가 멈춰도 다음 사이클 청산 판정이 돈다. 진입은 중복 시작하지 않는다.""" + import app.services.swing.scheduler as sch_mod + never = asyncio.Event() + entry_calls = {"n": 0} + + async def _hang_entry(*a, **k): + entry_calls["n"] += 1 + await never.wait() + + exit_mock = AsyncMock(return_value=[]) + sch, _api, _calls = _loop_env(monkeypatch, exit_mock=exit_mock) + monkeypatch.setattr(sch_mod, "_run_entry_phase_v2", _hang_entry) + monkeypatch.setattr(sch, "_DEFAULT_ENTRY_PHASE_TIMEOUT_SEC", 0.05) + + await _one_cycle(sch) + await _one_cycle(sch) + + assert exit_mock.await_count == 2 + assert entry_calls["n"] == 1 + assert not sch._inflight_steps["진입"].done() + + +@pytest.mark.asyncio +async def test_guard_detached_propagates_step_exception(_reset_state): + sch = _reset_state.sch + + async def _boom(): + raise RuntimeError("x") + + with pytest.raises(RuntimeError): + await sch._guard_detached_v2(_boom, "t", 1.0) + assert "t" not in sch._inflight_steps + + +@pytest.fixture +def _real_settings_env(monkeypatch, tmp_path): + """[R4] 실제 settings 인스턴스 기준. env_file 은 빈 임시 파일로 고정하고 캐시를 비운다.""" + import app.services.swing.scheduler as sch + from app.core.config import settings as real_settings + monkeypatch.setattr(sch, "settings", real_settings) + env_file = tmp_path / ".env" + env_file.write_text("", encoding="utf-8") + monkeypatch.setitem(type(real_settings).model_config, "env_file", str(env_file)) + monkeypatch.setattr(sch, "_env_file_cache", {}) + monkeypatch.setattr(sch, "_undeclared_env_warned", set()) + for k in ("SWING_EXIT_STEP_TIMEOUT_SEC", "MDD_STAGE_CONFIRM_CYCLES", "MDD_BLIND_FAILURE_THRESHOLD"): + monkeypatch.delenv(k, raising=False) + return SimpleNamespace(sch=sch, settings=real_settings, env_file=env_file) + + +def test_cfg_float_env_override_applies_on_real_settings(monkeypatch, _real_settings_env, caplog): + """[R4] 미선언 키도 .env/compose 오버라이드가 실제로 적용돼야 한다(조용히 무시 금지).""" + sch, st = _real_settings_env.sch, _real_settings_env.settings + # 전제: Settings(extra=ignore)에 미선언 → getattr 만으로는 늘 기본값이었다 + assert getattr(st, "SWING_EXIT_STEP_TIMEOUT_SEC", None) is None + assert sch._cfg_float_v2("SWING_EXIT_STEP_TIMEOUT_SEC", 180.0) == 180.0 + monkeypatch.setenv("SWING_EXIT_STEP_TIMEOUT_SEC", "42") + with caplog.at_level("WARNING"): + assert sch._cfg_float_v2("SWING_EXIT_STEP_TIMEOUT_SEC", 180.0) == 42.0 + assert any("미선언" in r.getMessage() for r in caplog.records) + # 0/음수/문자열은 상한을 끄지 않고 기본값으로 + for bad in ("0", "-1", "abc"): + monkeypatch.setenv("SWING_EXIT_STEP_TIMEOUT_SEC", bad) + assert sch._cfg_float_v2("SWING_EXIT_STEP_TIMEOUT_SEC", 180.0) == 180.0 + + +def test_cfg_int_env_file_override_applies_on_real_settings(_real_settings_env): + """[R4] Settings 의 env_file(.env) 에만 있는 미선언 키도 적용된다.""" + sch, st = _real_settings_env.sch, _real_settings_env.settings + assert getattr(st, "MDD_STAGE_CONFIRM_CYCLES", None) is None + _real_settings_env.env_file.write_text("MDD_STAGE_CONFIRM_CYCLES=4\n", encoding="utf-8") + assert sch._cfg_int_v2("MDD_STAGE_CONFIRM_CYCLES", sch._DEFAULT_MDD_STAGE_CONFIRM_CYCLES) == 4 + # minimum 미만은 기본값 + sch._env_file_cache.clear() + _real_settings_env.env_file.write_text("MDD_STAGE_CONFIRM_CYCLES=0\n", encoding="utf-8") + assert (sch._cfg_int_v2("MDD_STAGE_CONFIRM_CYCLES", sch._DEFAULT_MDD_STAGE_CONFIRM_CYCLES) + == sch._DEFAULT_MDD_STAGE_CONFIRM_CYCLES) + + +def test_cfg_declared_settings_attr_wins_over_env(monkeypatch, _real_settings_env): + """[R4] config.py 에 선언된 키는 settings 속성이 1순위(환경변수 직접 읽기는 미선언 키 한정).""" + sch, st = _real_settings_env.sch, _real_settings_env.settings + monkeypatch.setattr(st, "MDD_BLIND_FAILURE_THRESHOLD", 7) + monkeypatch.setenv("MDD_BLIND_FAILURE_THRESHOLD", "99") + assert sch._cfg_int_v2("MDD_BLIND_FAILURE_THRESHOLD", 5) == 7 + + +# ═══════════════════════════════════════════════════════════════════════════ +# 결함 1 — 청산 판정 하트비트 +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_exit_stale_alert_during_market_hours(_reset_state): + sch = _reset_state.sch + limit = sch._DEFAULT_EXIT_STALE_ALERT_SEC + sch._note_market_open_v2(1000.0) + assert sch._check_exit_staleness_v2(1000.0 + limit - 1) is False + assert sch._check_exit_staleness_v2(1000.0 + limit + 1) is True + await _drain() + assert any("청산 판정" in m and "완료되지 않음" in m for m in _messages(_reset_state.notify)) + # 재경보는 throttle + assert sch._check_exit_staleness_v2(1000.0 + limit + 2) is True + await _drain() + assert _reset_state.notify.await_count == 1 + + +@pytest.mark.asyncio +async def test_exit_stale_ignores_overnight_gap(_reset_state): + """전일 마지막 성공 이후 밤사이 공백은 세지 않는다 — 기준은 이번 장중 첫 관측.""" + sch = _reset_state.sch + sch._exit_health["last_success_at"] = 0.0 # 아주 오래 전(전일) + sch._note_market_closed_v2() + sch._note_market_open_v2(100000.0) + assert sch._check_exit_staleness_v2(100000.0 + 60) is False + h = sch.get_swing_scheduler_health_v2() + assert "exit_stale" in h and "exit_unjudged_market_sec" in h + + +def test_exit_stale_not_checked_when_market_closed(_reset_state): + sch = _reset_state.sch + sch._note_market_closed_v2() + assert sch._check_exit_staleness_v2(10 ** 9) is False + + +@pytest.mark.asyncio +async def test_cycle_and_exit_heartbeats_recorded(monkeypatch, _reset_state): + from app.core import heartbeat as hb + hb.reset() + sch, _api, _calls = _loop_env(monkeypatch) + await _one_cycle(sch) + assert hb.age_seconds("swing") is not None + assert hb.age_seconds("swing_exit") is not None + assert sch._exit_health["last_success_at"] is not None + hb.reset() + + +@pytest.mark.asyncio +async def test_exit_failure_does_not_beat_exit_heartbeat(monkeypatch, _reset_state): + from app.core import heartbeat as hb + hb.reset() + sch, _api, _calls = _loop_env(monkeypatch, exit_mock=AsyncMock(side_effect=RuntimeError("kis down"))) + await _one_cycle(sch) + assert hb.age_seconds("swing") is not None + assert hb.age_seconds("swing_exit") is None + assert sch._exit_health["consecutive_failures"] == 1 + hb.reset() + + +# ═══════════════════════════════════════════════════════════════════════════ +# 결함 3 — 텔레그램 경보 +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_exit_consecutive_failures_alert_at_threshold_then_recovery(monkeypatch, _reset_state): + import app.services.swing.fill_sweep as fs + sch = _reset_state.sch + monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM()) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(return_value=0)) + monkeypatch.setattr(sch, "check_and_execute_exit_v2", AsyncMock(side_effect=RuntimeError("boom"))) + threshold = sch._DEFAULT_EXIT_FAIL_ALERT_COUNT + + for _ in range(threshold - 1): + await sch._run_exit_phase_v2() + await _drain() + assert _reset_state.notify.await_count == 0 + + await sch._run_exit_phase_v2() + await _drain() + assert _reset_state.notify.await_count == 1 + assert "연속 실패" in _messages(_reset_state.notify)[0] + + # 복구 시 1회 알림 + 카운터 리셋 + monkeypatch.setattr(sch, "check_and_execute_exit_v2", AsyncMock(return_value=[])) + await sch._run_exit_phase_v2() + await _drain() + assert sch._exit_health["consecutive_failures"] == 0 + assert _reset_state.notify.await_count == 2 + assert "복구" in _messages(_reset_state.notify)[1] + + +@pytest.mark.asyncio +async def test_exit_failure_still_runs_fill_sweep(monkeypatch, _reset_state): + """[R8] 청산 판정이 실패해도 스케줄러 루프의 별도 단계에서 sweep 은 돈다.""" + import app.services.swing.fill_sweep as fs + sch, _api, _calls = _loop_env( + monkeypatch, exit_mock=AsyncMock(side_effect=RuntimeError("boom"))) + sweep = AsyncMock(return_value=0) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", sweep) + await _one_cycle(sch) + sweep.assert_awaited_once() + assert sch._exit_health["consecutive_failures"] == 1 + + +# ═══════════════════════════════════════════════════════════════════════════ +# 하드닝 R8 — sweep 을 청산 판정의 비취소형 상한에서 분리 +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_r8_exit_phase_does_not_run_sweep(monkeypatch, _reset_state): + """청산 판정 단계 자체는 sweep 을 호출하지 않는다(상한 공유 제거).""" + import app.services.swing.fill_sweep as fs + sch = _reset_state.sch + monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM()) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", + AsyncMock(side_effect=AssertionError("청산 판정 안에서 sweep 금지"))) + monkeypatch.setattr(sch, "check_and_execute_exit_v2", AsyncMock(return_value=[])) + await sch._run_exit_phase_v2() + assert sch._exit_health["consecutive_failures"] == 0 + assert sch._exit_health["last_success_at"] is not None + + +@pytest.mark.asyncio +async def test_r8_slow_sweep_does_not_mark_exit_failed_or_skip_next_cycle(monkeypatch, _reset_state): + """[재현 반전] 이전: 청산 판정 + sweep 합이 청산 상한을 넘으면 청산 성공인데도 + _STEP_TIMED_OUT → 실패 집계, 다음 사이클 _STEP_SKIPPED → 또 실패 집계.""" + import app.services.swing.fill_sweep as fs + exit_mock = AsyncMock(return_value=[]) + sch, _api, calls = _loop_env(monkeypatch, exit_mock=exit_mock) + sweep_started = [] + sweep_cancelled = [] + never = asyncio.Event() + + async def _hang_sweep(db): + sweep_started.append(1) + try: + await never.wait() + except asyncio.CancelledError: + sweep_cancelled.append(1) + raise + + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", _hang_sweep) + # sweep 상한 < 청산 상한 < (청산 + sweep 상한) — 이전 구조에서 청산 상한 초과를 재현하는 구성 + monkeypatch.setattr(sch, "_DEFAULT_EXIT_STEP_TIMEOUT_SEC", 0.1) + monkeypatch.setattr(sch, "_DEFAULT_FILL_SWEEP_TIMEOUT_SEC", 0.15) + + await _one_cycle(sch) + await _one_cycle(sch) + await _drain() + + assert exit_mock.await_count == 2 # 두 번째 사이클도 생략되지 않음 + assert sch._exit_health["consecutive_failures"] == 0 + assert sch._exit_health["timeouts"] == 0 + assert "청산 판정" not in sch._step_timeouts + assert sch._step_timeouts.get("체결가 후속 보정") == 2 # sweep 만 취소형으로 상한 초과 + assert len(sweep_started) == 2 and len(sweep_cancelled) == 2 + assert calls["entry"] == 2 + assert not any("연속 실패" in m for m in _messages(_reset_state.notify)) + + +@pytest.mark.asyncio +async def test_r8_sweep_skipped_while_exit_inflight(monkeypatch, _reset_state): + """청산 판정이 상한 초과로 백그라운드 진행 중이면 sweep 은 이번 사이클 생략(동시 보정 방지).""" + import app.services.swing.fill_sweep as fs + never = asyncio.Event() + + async def _hang(db): + await never.wait() + + sch, _api, _calls = _loop_env(monkeypatch, exit_mock=AsyncMock(side_effect=_hang)) + sweep = AsyncMock(side_effect=AssertionError("청산 판정 진행 중 sweep 금지")) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", sweep) + monkeypatch.setattr(sch, "_DEFAULT_EXIT_STEP_TIMEOUT_SEC", 0.05) + await _one_cycle(sch) + sweep.assert_not_awaited() + never.set() + await _drain() + + +@pytest.mark.asyncio +async def test_r8_sweep_exception_does_not_count_as_exit_failure(monkeypatch, _reset_state): + import app.services.swing.fill_sweep as fs + sch, _api, calls = _loop_env(monkeypatch) + monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(side_effect=RuntimeError("db down"))) + await _one_cycle(sch) + assert sch._exit_health["consecutive_failures"] == 0 + assert sch._exit_health["last_success_at"] is not None + assert calls["entry"] == 1 + + +@pytest.mark.asyncio +async def test_mdd_blind_block_alert_on_enter_and_release(monkeypatch, _reset_state): + sch = _reset_state.sch + state = {"h": {"blind_block_active": True, "consecutive_failures": 5, "last_error": "KIS"}} + monkeypatch.setattr(sch.mdd_manager, "get_update_health", lambda: state["h"]) + + sch._check_blind_block_transition_v2() + sch._check_blind_block_transition_v2() # 유지 중에는 재전송 없음 + await _drain() + assert _reset_state.notify.await_count == 1 + assert "신규 매수 전면 차단" in _messages(_reset_state.notify)[0] + + state["h"] = {"blind_block_active": False} + sch._check_blind_block_transition_v2() + await _drain() + assert _reset_state.notify.await_count == 2 + assert "해제" in _messages(_reset_state.notify)[1] + + +@pytest.mark.asyncio +async def test_blind_block_alert_fires_from_scheduler_loop(monkeypatch, _reset_state): + sch, _api, _calls = _loop_env(monkeypatch, can_open=False) + monkeypatch.setattr(sch.mdd_manager, "get_update_health", + lambda: {"blind_block_active": True, "consecutive_failures": 5, "last_error": "e"}) + monkeypatch.setattr(sch.mdd_manager, "reduction_applied", None) + await _one_cycle(sch) + await _drain() + assert any("신규 매수 전면 차단" in m for m in _messages(_reset_state.notify)) + + +@pytest.mark.asyncio +async def test_telegram_send_failure_does_not_break_loop(monkeypatch, _reset_state): + import app.services.telegram_notifier as tn + sch = _reset_state.sch + _reset_state.notify.side_effect = RuntimeError("tg down") + monkeypatch.setattr(sch, "_DEFAULT_ALERT_RETRY_BACKOFF_SEC", 0.001) + assert sch._notify_throttled_v2("k", "C", "m") is True + for _ in range(20): + await _real_sleep(0.005) # 백그라운드 예외는 실패 누계로 흡수(루프 비차단) + assert sch._telegram_send_health["failures"] == 1 + + +def test_telegram_unconfigured_logged_and_exposed(monkeypatch, _reset_state, caplog): + sch = _reset_state.sch + import app.services.telegram_notifier as tn + monkeypatch.setattr(tn, "_is_enabled", lambda: False) + with caplog.at_level("ERROR"): + assert sch._check_telegram_configured_v2() is False + assert any("텔레그램 미설정" in r.getMessage() for r in caplog.records) + assert sch.get_swing_scheduler_health_v2()["telegram_enabled"] is False + + +# ═══════════════════════════════════════════════════════════════════════════ +# 하드닝 라운드1 — 운용 중 계좌의 tot_evlu_amt<=0 은 '빈 계좌'가 아니라 갱신 실패 +# ═══════════════════════════════════════════════════════════════════════════ + +_ZERO_BALANCE = {"output2": [{"tot_evlu_amt": "0"}]} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("peak,last", [(50_000_000.0, 0.0), (0.0, 30_000_000.0)]) +async def test_zero_total_with_known_balance_is_update_failure(monkeypatch, _reset_state, peak, last): + """peak 또는 마지막 잔고가 >0 인데 0 응답 → record_update_failure, 빈 계좌 성공 기록 금지.""" + sch, api, calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=_ZERO_BALANCE)) + monkeypatch.setattr(sch.mdd_manager, "peak_balance", peak) + monkeypatch.setattr(sch.mdd_manager, "_last_balance", last) + + await _one_cycle(sch) + + sch.mdd_manager.record_update_failure.assert_awaited_once() + sch.mdd_manager.record_update_success.assert_not_awaited() + sch.mdd_manager.update.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_zero_total_on_fresh_account_still_empty_account(monkeypatch, _reset_state): + """자산 관측 이력이 없는(peak=0, last=0) 신규 계좌의 0 응답은 기존대로 빈 계좌 처리.""" + sch, api, calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=_ZERO_BALANCE)) + monkeypatch.setattr(sch.mdd_manager, "peak_balance", 0.0) + monkeypatch.setattr(sch.mdd_manager, "_last_balance", 0.0) + + await _one_cycle(sch) + + sch.mdd_manager.record_update_success.assert_awaited_once_with(empty_account=True) + sch.mdd_manager.record_update_failure.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_repeated_zero_total_on_live_account_triggers_blind_block(monkeypatch, _reset_state): + """실제 MDDManager 로: 운용 계좌에서 0 응답이 임계 횟수 반복되면 신규 진입이 차단된다.""" + from app.services.mdd_manager import MDDManager + + sch, api, calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=_ZERO_BALANCE)) + real = MDDManager() + real.peak_balance = 50_000_000.0 + monkeypatch.setattr(sch, "mdd_manager", real) + monkeypatch.setattr(real, "update", AsyncMock(side_effect=AssertionError("0 응답으로 update 금지"))) + monkeypatch.setattr(settings, "MDD_BLIND_BLOCK_ENTRY", True) + + threshold = max(1, int(settings.MDD_BLIND_FAILURE_THRESHOLD)) + for _ in range(threshold): + await _one_cycle(sch) + + health = real.get_update_health() + assert health["empty_account"] is False + assert health["consecutive_failures"] == threshold + assert real._is_update_blind() is True diff --git a/backend/tests/test_swing_scheduler_hardening_r3_20261009.py b/backend/tests/test_swing_scheduler_hardening_r3_20261009.py new file mode 100644 index 0000000..f6acaa7 --- /dev/null +++ b/backend/tests/test_swing_scheduler_hardening_r3_20261009.py @@ -0,0 +1,328 @@ +"""하드닝 R3 — 스윙 스케줄러 회귀 테스트 (swing/scheduler.py). + +결함: + 1. 비취소형 단계가 무기한 정체되면 이후 모든 사이클이 _STEP_SKIPPED 로 영구 생략(하드 데드라인 없음) + 2. 텔레그램 경보 전송 실패가 '전송됨'으로 처리되어 재경보 간격 동안 억제, 실패 누계·/health 노출 없음 + 3. MDD 단계 전이 예측이 settings 임계를 써서 mdd_manager(-10/-20 리터럴)와 어긋남 + DB 손잡이 무효 + +목은 공통 종단(_send_system_warning_checked_v2 / telegram_notifier.send_message / +strategy_cfg.aget / MDDManager Redis 쓰기)에 건다. 텔레그램 실전송은 예외 발생형 목으로 막는다. +""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.core.config import settings + +_real_sleep = asyncio.sleep + +import app.services.swing.scheduler as _sch_mod # noqa: E402 + +_REAL_CHECKED_SEND = _sch_mod._send_system_warning_checked_v2 + + +@pytest.fixture(autouse=True) +def env(monkeypatch): + import app.services.swing.scheduler as sch + import app.services.telegram_notifier as tn + + monkeypatch.setattr(sch, "_inflight_steps", {}) + monkeypatch.setattr(sch, "_inflight_meta", {}) + monkeypatch.setattr(sch, "_alert_sent_at", {}) + monkeypatch.setattr(sch, "_step_timeouts", {}) + monkeypatch.setattr(sch, "_hard_deadline_stats", {"cancels": 0, "exceeded": {}}) + monkeypatch.setattr(sch, "_telegram_send_health", { + "sent": 0, "failures": 0, "consecutive_failures": 0, + "last_success_at": None, "last_failure_at": None, "last_error": None, + }) + monkeypatch.setattr(sch, "_mdd_knob_state", {"mismatches": []}) + monkeypatch.setattr(sch, "_telegram_state", {"enabled": True}) + # 텔레그램 실전송 차단 — 예외 발생형(목킹 누락이 거짓 통과가 되지 않게) + monkeypatch.setattr(tn.telegram_notifier, "send_message", + AsyncMock(side_effect=AssertionError("텔레그램 실전송 금지"))) + monkeypatch.setattr(tn.telegram_notifier, "notify_system_warning", + AsyncMock(side_effect=AssertionError("텔레그램 실전송 금지"))) + notify = AsyncMock(return_value=True) + monkeypatch.setattr(sch, "_send_system_warning_checked_v2", notify) + holder = SimpleNamespace(sch=sch, tn=tn, notify=notify, tasks=[]) + yield holder + for t in list(sch._inflight_steps.values()) + holder.tasks: + if not t.done(): + t.cancel() + + +async def _drain(n=10): + for _ in range(n): + await _real_sleep(0.002) + + +def _messages(notify): + return [c.kwargs.get("message", "") for c in notify.await_args_list] + + +def _age_out(sch, step): + """하드 데드라인을 넘긴 것으로 시작 시각을 과거로 민다.""" + meta = sch._inflight_meta[step] + meta["started"] -= meta["hard"] + 1.0 + + +# ═══════════════════════════════════════════════════════════════════════════ +# 결함 1 — 하드 데드라인 +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_stuck_exit_step_is_cancelled_after_hard_deadline_and_restarted(env): + sch = env.sch + never = asyncio.Event() + calls = {"n": 0} + + async def _factory(): + calls["n"] += 1 + if calls["n"] == 1: + await never.wait() # 락 대기처럼 영원히 멈춤 + return ["ok"] + + assert await sch._guard_detached_v2(_factory, "청산 판정", 0.02) is sch._STEP_TIMED_OUT + stuck = sch._inflight_steps["청산 판정"] + # 하드 데드라인 전에는 생략(취소 안 함) + assert await sch._guard_detached_v2(_factory, "청산 판정", 0.02) is sch._STEP_SKIPPED + assert not stuck.done() + + _age_out(sch, "청산 판정") + res = await sch._guard_detached_v2(_factory, "청산 판정", 0.5) + assert res == ["ok"] # 같은 사이클에 재시작되어 완료 + assert stuck.cancelled() + assert sch._hard_deadline_stats["cancels"] == 1 + await _drain() + assert any("강제 취소" in m for m in _messages(env.notify)) + + +@pytest.mark.asyncio +async def test_exit_step_not_cancelled_while_shielded_order_in_flight(env): + """shield 주문(_sell_and_record_v2)이 살아 있으면 재시작하지 않는다 — 이중 분할매도 방지.""" + sch = env.sch + never = asyncio.Event() + + async def _sell_and_record_v2(): # swing/exit_service 의 shield 내부 코루틴 이름 + await never.wait() + + order_task = asyncio.ensure_future(_sell_and_record_v2()) + env.tasks.append(order_task) + + async def _factory(): + await asyncio.shield(order_task) + + assert await sch._guard_detached_v2(_factory, "청산 판정", 0.02) is sch._STEP_TIMED_OUT + stuck = sch._inflight_steps["청산 판정"] + _age_out(sch, "청산 판정") + assert await sch._guard_detached_v2(_factory, "청산 판정", 0.02) is sch._STEP_SKIPPED + assert not stuck.done() and not order_task.done() + assert sch._hard_deadline_stats["cancels"] == 0 + await _drain() + assert any("shield 주문" in m for m in _messages(env.notify)) + + +@pytest.mark.asyncio +async def test_order_path_steps_never_cancelled_but_reported_degraded(env): + sch = env.sch + never = asyncio.Event() + + async def _factory(): + await never.wait() + + for step in ("MDD 축소", "진입"): + assert await sch._guard_detached_v2(_factory, step, 0.02) is sch._STEP_TIMED_OUT + _age_out(sch, step) + assert await sch._guard_detached_v2(_factory, step, 0.02) is sch._STEP_SKIPPED + assert not sch._inflight_steps[step].done() + await _drain() + assert sum("중복 주문 방지" in m for m in _messages(env.notify)) == 2 + + h = sch.get_swing_scheduler_health_v2() + assert h["status"] == "degraded" + assert "stuck_steps" in h["degraded_reasons"] + assert h["stuck_steps"] == sorted(["MDD 축소", "진입"]) + assert h["inflight_step_ages"]["진입"] > 0 + assert h["hard_deadline_exceeded"] == {"MDD 축소": 1, "진입": 1} + + +@pytest.mark.asyncio +async def test_cancel_ignoring_task_is_not_restarted(env, monkeypatch): + sch = env.sch + monkeypatch.setattr(sch, "_DEFAULT_STEP_CANCEL_GRACE_SEC", 0.02) + release = asyncio.Event() + + async def _stubborn(): + while not release.is_set(): + try: + await _real_sleep(0.005) + except asyncio.CancelledError: + continue # 취소 무시 + + assert await sch._guard_detached_v2(_stubborn, "청산 판정", 0.01) is sch._STEP_TIMED_OUT + _age_out(sch, "청산 판정") + assert await sch._guard_detached_v2(_stubborn, "청산 판정", 0.01) is sch._STEP_SKIPPED + assert sch._hard_deadline_stats["cancels"] == 0 + release.set() + await _drain() + + +def test_hard_deadline_multiplier_from_settings_floor(env, monkeypatch): + sch = env.sch + assert sch._hard_deadline_sec_v2(10.0) == pytest.approx(10.0 * sch._DEFAULT_STEP_HARD_DEADLINE_MULT) + + +# ═══════════════════════════════════════════════════════════════════════════ +# 결함 2 — 텔레그램 전송 실패 추적 +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_failed_alert_is_retried_counted_and_throttle_rolled_back(env, monkeypatch): + sch = env.sch + clock = [1000.0] + monkeypatch.setattr(sch, "_now_mono_v2", lambda: clock[0]) + monkeypatch.setattr(sch, "_DEFAULT_ALERT_RETRY_BACKOFF_SEC", 0.001) + env.notify.return_value = False # 비200 응답 등 + assert sch._notify_throttled_v2("exit_failures", "SWING_EXIT", "청산 정지") is True + await _drain() + attempts = sch._DEFAULT_ALERT_SEND_RETRIES + 1 + assert env.notify.await_count == attempts + h = sch.get_swing_scheduler_health_v2() + assert h["telegram_send_failures"] == 1 + assert h["telegram_consecutive_failures"] == 1 + assert "telegram_send_failing" in h["degraded_reasons"] + + # 원래 재경보 간격(SWING_ALERT_REPEAT_SEC)이 아니라 짧은 재시도 간격 뒤 다시 보낸다 + clock[0] += sch._DEFAULT_ALERT_FAIL_RETRY_SEC + 1 + env.notify.return_value = True + assert sch._notify_throttled_v2("exit_failures", "SWING_EXIT", "청산 정지") is True + await _drain() + h = sch.get_swing_scheduler_health_v2() + assert h["telegram_consecutive_failures"] == 0 and h["telegram_sent"] == 1 + assert h["telegram_last_success_age"] == 0.0 + + +@pytest.mark.asyncio +async def test_successful_alert_keeps_throttle(env, monkeypatch): + sch = env.sch + clock = [1000.0] + monkeypatch.setattr(sch, "_now_mono_v2", lambda: clock[0]) + assert sch._notify_throttled_v2("k", "C", "m") is True + await _drain() + clock[0] += sch._DEFAULT_ALERT_FAIL_RETRY_SEC + 1 + assert sch._notify_throttled_v2("k", "C", "m") is False # 성공 후엔 repeat_sec 동안 억제 + assert sch._telegram_send_health["failures"] == 0 + + +@pytest.mark.asyncio +async def test_unconfigured_telegram_is_not_counted_as_failure(env): + sch = env.sch + env.notify.return_value = None + assert sch._notify_throttled_v2("k", "C", "m") is True + await _drain() + assert env.notify.await_count == 1 + assert sch._telegram_send_health["failures"] == 0 + + +@pytest.mark.asyncio +async def test_checked_send_returns_transport_result(env, monkeypatch): + """공통 종단: send_message(bool) 결과를 그대로 돌려준다. 미설정이면 전송하지 않고 None.""" + tn = env.tn + fn = _REAL_CHECKED_SEND + + monkeypatch.setattr(tn, "_is_enabled", lambda: False) + assert await fn(category="C", message="m") is None # send_message 목은 예외 발생형 → 미호출 증명 + + monkeypatch.setattr(tn, "_is_enabled", lambda: True) + send = AsyncMock(return_value=False) + monkeypatch.setattr(tn.telegram_notifier, "send_message", send) + assert await fn(category="SWING_EXIT", message="청산 정지") is False + assert "SWING" in send.await_args.args[0] + send.return_value = True + assert await fn(category="C", message="m") is True + + +# ═══════════════════════════════════════════════════════════════════════════ +# 결함 3 — MDD 임계 정합 +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.fixture +def mdd_env(env, monkeypatch): + from app.services.mdd_manager import MDDManager + sch = env.sch + mgr = MDDManager() + + async def _no_write(key, state): + return None + + monkeypatch.setattr(mgr, "_write_state_v2", _no_write) + monkeypatch.setattr(mgr, "save_to_redis", AsyncMock()) + monkeypatch.setattr(sch, "mdd_manager", mgr) + monkeypatch.setattr(sch, "_mdd_gate_state", {"prev": None, "pending": 0, "suspect": 0, "last_flow": None}) + monkeypatch.setattr(env.tn.telegram_notifier, "notify_mdd_warning", AsyncMock()) + return sch, mgr + + +def _resp(total, scts): + return {"output1": [], "output2": [{"tot_evlu_amt": str(total), "scts_evlu_amt": str(scts)}]} + + +@pytest.mark.asyncio +async def test_shallower_setting_does_not_create_pending_without_transition(mdd_env, monkeypatch): + """settings 임계가 -8 이어도 mdd_manager 는 -10 에서만 전이 → -9% 구간에서 pending(진입 차단)만 + 반복되던 결함. 예측은 실제 전이와 같아야 한다.""" + sch, mgr = mdd_env + monkeypatch.setattr(settings, "MDD_PAUSE_THRESHOLD", -8.0) + await sch._mdd_update_guarded_v2(10_000_000.0, _resp(10_000_000, 6_000_000)) + pause = sch._mdd_stage_thresholds_v2()[0] + total = 10_000_000 * (1 + (pause + 1.0) / 100.0) # 실효 임계보다 1%p 얕은 낙폭(-9%) + need = sch._DEFAULT_MDD_STAGE_CONFIRM_CYCLES + for _ in range(need + 2): + r = await sch._mdd_update_guarded_v2(total, _resp(total, total - 4_000_000)) + assert r["status"] == "applied" + assert sch._mdd_gate_state["pending"] == 0 + assert mgr.reduction_applied is None and not mgr.is_paused + + +@pytest.mark.asyncio +async def test_prediction_matches_mdd_manager_transition(mdd_env): + sch, mgr = mdd_env + await sch._mdd_update_guarded_v2(10_000_000.0, _resp(10_000_000, 6_000_000)) + pause = sch._mdd_stage_thresholds_v2()[0] + total = 10_000_000 * (1 + (pause - 0.5) / 100.0) + need = sch._DEFAULT_MDD_STAGE_CONFIRM_CYCLES + for i in range(need): + r = await sch._mdd_update_guarded_v2(total, _resp(total, total - 4_000_000)) + assert r["status"] == "applied" + assert mgr.reduction_applied == "10pct" # 예측한 단계로 실제 전이 + + +@pytest.mark.asyncio +async def test_mdd_threshold_knob_mismatch_alerts_and_degrades(env, monkeypatch): + sch = env.sch + from app.services.strategy_config_service import strategy_cfg + vals = {"MDD_PAUSE_THRESHOLD": -8.0, "MDD_FORCE_THRESHOLD": -20.0} + monkeypatch.setattr(strategy_cfg, "aget", AsyncMock(side_effect=lambda k, d=None: vals[k])) + monkeypatch.setattr(settings, "MDD_PAUSE_THRESHOLD", sch._MDD_MANAGER_PAUSE_PCT) + monkeypatch.setattr(settings, "MDD_FORCE_THRESHOLD", sch._MDD_MANAGER_FORCE_PCT) + mism = await sch._check_mdd_threshold_knobs_v2() + assert [(m["source"], m["key"]) for m in mism] == [("strategy_cfg", "MDD_PAUSE_THRESHOLD")] + await _drain() + assert any("효과 없음" in m for m in _messages(env.notify)) + assert "mdd_threshold_knob_mismatch" in sch.get_swing_scheduler_health_v2()["degraded_reasons"] + + vals["MDD_PAUSE_THRESHOLD"] = sch._MDD_MANAGER_PAUSE_PCT + assert await sch._check_mdd_threshold_knobs_v2() == [] + assert sch.get_swing_scheduler_health_v2()["mdd_threshold_knob_mismatch"] == [] + + +@pytest.mark.asyncio +async def test_mdd_knob_db_failure_does_not_break(env, monkeypatch): + sch = env.sch + from app.services.strategy_config_service import strategy_cfg + monkeypatch.setattr(strategy_cfg, "aget", AsyncMock(side_effect=RuntimeError("db down"))) + monkeypatch.setattr(settings, "MDD_PAUSE_THRESHOLD", -8.0) + mism = await sch._check_mdd_threshold_knobs_v2() + assert [(m["source"], m["key"]) for m in mism] == [("settings", "MDD_PAUSE_THRESHOLD")] diff --git a/backend/tests/test_swing_trailing_mult_20261009.py b/backend/tests/test_swing_trailing_mult_20261009.py new file mode 100644 index 0000000..8362b2d --- /dev/null +++ b/backend/tests/test_swing_trailing_mult_20261009.py @@ -0,0 +1,157 @@ +"""[R3 2026-10-09] 스윙 ATR 트레일링 배수 단일 출처 회귀 테스트. + +결함: 평균회귀 check_exit_signal·모멘텀 _check_momentum_exit 가 리터럴 1.5 를 써 +config / strategy_cfg(DB) / 적응형 어느 값도 소비되지 않았다. +수정: resolve_swing_trailing_atr_mult_v2 (strategy_cfg > config, 적응형은 +SWING_ADAPTIVE_TRAILING_ENABLED 일 때만 — 백테스트 전 기본 OFF). +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import app.services.mean_reversion_strategy as mr +from app.core.config import settings +from app.services.strategy_config_service import DEFAULT_PARAMS + + +class _AdaptiveCalled(Exception): + """플래그 OFF 인데 적응형을 조회하면 실패시키는 예외 발생형 목.""" + + +def _cfg(values): + m = MagicMock() + m.get.side_effect = lambda key, default=None: values.get(key, default) + return m + + +_FALLBACK_ADP = SimpleNamespace(fallback_used=True) + + +@pytest.fixture +def no_adaptive(): + """적응형 오버레이 없음(fallback_used=True) — 손절/RSI 경로 결정론.""" + with patch("app.services.adaptive_strategy.get_current_adaptive_params", + return_value=_FALLBACK_ADP): + yield + + +def _exit(close, cfg_values, trailing_high=11000.0, atr=200.0): + with patch.object(mr, "strategy_cfg", _cfg(cfg_values)): + return mr.check_exit_signal( + regime=mr.MarketRegime.BULL, rsi=50.0, close=close, + bb_upper=20000.0, bb_middle=9000.0, bb_lower=8000.0, + entry_price=10000.0, atr=atr, lowest_since_entry=9800.0, + remaining_pct=0.50, trailing_high=trailing_high, + ) + + +def test_default_uses_config_value(no_adaptive): + with patch.object(mr, "strategy_cfg", _cfg({})): + mult, source = mr._resolve_swing_trailing_atr_mult_impl() + assert mult == float(settings.ATR_TRAILING_MULTIPLIER) + assert source == "config" + + +def test_strategy_cfg_overrides_config_and_reaches_exit(no_adaptive): + cfg_mult = 2.0 + assert cfg_mult != float(settings.ATR_TRAILING_MULTIPLIER) + atr, high = 200.0, 11000.0 + # config 배수 트레일과 DB 배수 트레일 사이의 종가 → DB 값이면 미청산이어야 한다 + close = high - ((float(settings.ATR_TRAILING_MULTIPLIER) + cfg_mult) / 2) * atr + if cfg_mult > float(settings.ATR_TRAILING_MULTIPLIER): + assert _exit(close, {"ATR_TRAILING_MULTIPLIER": cfg_mult}) is None + sig = _exit(close, {}) + assert sig is not None and sig.action == "SELL_ALL" and "트레일링" in sig.reason + assert f"{float(settings.ATR_TRAILING_MULTIPLIER):g}×ATR" in sig.reason + + +def test_out_of_range_db_value_falls_back_to_config(no_adaptive): + _hi = float(DEFAULT_PARAMS["ATR_TRAILING_MULTIPLIER"][6]) + for bad in (_hi + 1, 0, -1.5, "abc", float("nan")): + with patch.object(mr, "strategy_cfg", _cfg({"ATR_TRAILING_MULTIPLIER": bad})): + mult, source = mr._resolve_swing_trailing_atr_mult_impl() + assert source == "config", bad + assert mult == float(settings.ATR_TRAILING_MULTIPLIER) + + +def test_adaptive_not_consulted_when_flag_off(): + with patch("app.services.adaptive_strategy.get_current_adaptive_params", + side_effect=_AdaptiveCalled("적응형 조회 금지")), \ + patch.object(mr, "_adaptive_trailing_enabled_v2", return_value=False), \ + patch.object(mr, "strategy_cfg", _cfg({})): + mult, source = mr._resolve_swing_trailing_atr_mult_impl() + status = mr.get_swing_trailing_mult_status_v2() + assert source == "config" + assert mult == float(settings.ATR_TRAILING_MULTIPLIER) + assert status["adaptive_trailing_applied"] is False + assert status["adaptive_trailing_enabled"] is False + + +def test_flag_default_is_off(): + # 백테스트 전에는 적응형 배수(2.0/1.6/1.3/1.0)가 실전에 적용되면 안 된다 + assert getattr(settings, "SWING_ADAPTIVE_TRAILING_ENABLED", False) is not True + assert mr._adaptive_trailing_enabled_v2() is False + + +def test_adaptive_applied_when_flag_on(): + adp = SimpleNamespace(fallback_used=False, atr_trailing_multiplier=1.0) + with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=adp), \ + patch.object(mr, "_adaptive_trailing_enabled_v2", return_value=True), \ + patch.object(mr, "strategy_cfg", _cfg({"ATR_TRAILING_MULTIPLIER": 2.0})): + mult, source = mr._resolve_swing_trailing_atr_mult_impl() + assert (mult, source) == (1.0, "adaptive") + + +def test_adaptive_failure_falls_back_when_flag_on(): + with patch("app.services.adaptive_strategy.get_current_adaptive_params", + side_effect=RuntimeError("adaptive down")), \ + patch.object(mr, "_adaptive_trailing_enabled_v2", return_value=True), \ + patch.object(mr, "strategy_cfg", _cfg({"ATR_TRAILING_MULTIPLIER": 2.0})): + mult, source = mr._resolve_swing_trailing_atr_mult_impl() + assert (mult, source) == (2.0, "strategy_cfg") + + +def test_invalid_config_uses_meta_default(monkeypatch): + monkeypatch.setattr(settings, "ATR_TRAILING_MULTIPLIER", 0.0) + with patch.object(mr, "strategy_cfg", _cfg({})): + mult, source = mr._resolve_swing_trailing_atr_mult_impl() + assert source == "meta_default" + assert mult == float(DEFAULT_PARAMS["ATR_TRAILING_MULTIPLIER"][4]) > 0 + + +def test_momentum_trailing_uses_resolver(no_adaptive): + """공통 종단(resolve_swing_trailing_atr_mult_v2)을 예외 발생형으로 치환 → 모멘텀 경로가 반드시 호출.""" + from app.services.swing import exit_service + + class _ResolverHit(Exception): + pass + + with patch.object(mr, "strategy_cfg", _cfg({})), \ + patch.object(mr, "resolve_swing_trailing_atr_mult_v2", side_effect=_ResolverHit()): + with pytest.raises(_ResolverHit): + exit_service._check_momentum_exit( + current_price=10900.0, entry_price=10000.0, atr=200.0, + remaining_pct=0.40, trailing_high=12000.0, + ) + + +def test_momentum_trailing_honors_db_value(no_adaptive): + from app.services.swing import exit_service + atr, high = 200.0, 12000.0 + base = float(settings.ATR_TRAILING_MULTIPLIER) + cfg_mult = 2.0 + close = high - ((base + cfg_mult) / 2) * atr # 두 트레일 사이 + kwargs = dict(current_price=close, entry_price=10000.0, atr=atr, + remaining_pct=0.40, trailing_high=high) + with patch.object(mr, "strategy_cfg", _cfg({})): + sig = exit_service._check_momentum_exit(**kwargs) + assert sig is not None and "트레일링" in sig.reason + with patch.object(mr, "strategy_cfg", _cfg({"ATR_TRAILING_MULTIPLIER": cfg_mult})): + sig2 = exit_service._check_momentum_exit(**kwargs) + assert sig2 is None or "트레일링" not in sig2.reason diff --git a/backend/tests/test_trade_analyzer_v2_20261009.py b/backend/tests/test_trade_analyzer_v2_20261009.py new file mode 100644 index 0000000..1feb2de --- /dev/null +++ b/backend/tests/test_trade_analyzer_v2_20261009.py @@ -0,0 +1,119 @@ +"""trade_analyzer v2 (라운드트립 귀인 / 시스템 성과 분리 / 0 채움 Sharpe) 회귀 테스트. DB·네트워크 없음.""" +from datetime import date, datetime, timedelta +from types import SimpleNamespace as NS + +import pytest + +from app.services import trade_analyzer as ta + + +def _t(i, code, otype, ts, qty, price=10000, net=None, rate=None, st="swing", + entry_reason=None, exit_reason=None, link=None): + return NS(id=i, stock_code=code, order_type=otype, created_at=ts, + executed_quantity=qty, order_quantity=qty, executed_price=price, order_price=price, + net_profit_loss=net, profit_loss_rate=rate, strategy_type=st, + entry_reason=entry_reason, exit_reason=exit_reason, entry_trade_id=link, + stock_name=None) + + +D0 = datetime(2026, 9, 1, 10, 0) + + +def test_split_sell_is_one_position_fifo_when_entry_trade_id_null(): + buy = _t(1, "005930", "BUY", D0, 10, entry_reason="mean_reversion RSI") + s1 = _t(2, "005930", "SELL", D0 + timedelta(days=2), 5, 11000, net=4000, rate=10.0, exit_reason="익절") + s2 = _t(3, "005930", "SELL", D0 + timedelta(days=4), 5, 9000, net=-6000, rate=-10.0, exit_reason="손절") + m = ta._match_roundtrips_v2([buy], [s1, s2]) + assert all(x["buy"].id == 1 for x in m) + pos = ta._build_positions_v2(m) + assert len(pos) == 1 and pos[0]["n_exits"] == 2 + assert pos[0]["net_pnl"] == -2000 + assert pos[0]["closed"] is True + st = ta._position_stats_v2(pos) + # SELL 단위 승률 50% 이지만 포지션 단위는 손실 1건 = 0% + assert st["positions"] == 1 and st["win_rate"] == 0.0 + + +def test_open_partial_position_not_counted_as_win(): + buy = _t(1, "A", "BUY", D0, 10) + s1 = _t(2, "A", "SELL", D0 + timedelta(days=1), 5, 11000, net=4000, rate=10.0) + pos = ta._build_positions_v2(ta._match_roundtrips_v2([buy], [s1])) + st = ta._position_stats_v2(pos) + assert st["positions"] == 0 and st["open_partial_positions"] == 1 + + +def test_explicit_entry_trade_id_preferred(): + b1 = _t(1, "A", "BUY", D0, 10) + b2 = _t(2, "A", "BUY", D0 + timedelta(hours=1), 10) + s = _t(3, "A", "SELL", D0 + timedelta(days=1), 10, link=2, net=1, rate=1.0) + m = ta._match_roundtrips_v2([b1, b2], [s]) + assert m[0]["buy"].id == 2 + + +def test_unmatched_sell_is_standalone_orphan(): + s = _t(9, "Z", "SELL", D0, 3, net=100, rate=1.0) + pos = ta._build_positions_v2(ta._match_roundtrips_v2([], [s])) + assert pos[0]["entry_trade_id"] is None and pos[0]["closed"] + + +def test_manual_excluded_from_system_summary(): + sells = [ + _t(1, "A", "SELL", D0, 1, net=-1000, rate=-1.0, st="swing"), + _t(2, "B", "SELL", D0, 1, net=85000, rate=8.0, st="manual"), + _t(3, "C", "SELL", D0, 1, net=500, rate=0.5, st="daytrading"), + ] + perf = ta._compose_performance_v2(sells, [], [D0.date()], None, 30) + assert perf["summary"]["total_trades"] == 2 + assert perf["summary"]["net_pnl"] == -500 + assert perf["summary_all"]["net_pnl"] == 84500 + assert perf["external_summary"]["total_trades"] == 1 + # build_insights 호환 + assert isinstance(ta.build_insights(perf), list) + + +def _tdays(n): + out, d = [], date(2026, 8, 3) + while len(out) < n: + if d.weekday() < 5: + out.append(d) + d += timedelta(days=1) + return out + + +def test_zero_filled_daily_returns_and_real_capital_denominator(): + days = _tdays(40) + pnl = {days[0]: 100_000.0, days[1]: -100_000.0} + r = ta._daily_returns_v2(pnl, days, {days[0]: 50_000_000.0}) + assert len(r["returns"]) == 40 # 무거래일 포함 + assert r["returns"][0] == pytest.approx(100_000 / 50_000_000) + assert r["capital_source"] == "session_starting_balance" + legacy = ta._daily_returns_v2(pnl, days, None) + assert legacy["capital_source"] == "fallback_fixed_capital" + + +def test_zero_fill_lowers_sharpe_vs_active_days_only(): + days = _tdays(60) + pnl = {days[i]: (200_000.0 if i % 2 == 0 else -150_000.0) for i in range(0, 30)} + sells = [_t(i, "A", "SELL", datetime.combine(d, datetime.min.time()), 1, net=v, rate=1.0) + for i, (d, v) in enumerate(pnl.items())] + old = ta._calc_sharpe_ratio(ta._daily_pnl_to_returns(ta._build_daily_pnl_series(sells))) + new = ta._risk_metrics_v2(sells, days, None) + assert old is not None and new["sharpe_ratio"] is not None + assert new["sharpe_ratio"] != old + assert new["daily_returns_count"] == 60 + assert new["active_trading_days"] == 30 + assert new["sharpe_ci95"][0] < new["sharpe_ratio"] < new["sharpe_ci95"][1] + + +def test_low_sample_flag(): + days = _tdays(25) + m = ta._risk_metrics_v2([], days, None) + assert m["low_sample"] is True and m["sharpe_ratio"] is None + + +def test_holding_period_attribution(): + buy = _t(1, "A", "BUY", D0, 10, entry_reason="mean_reversion") + s = _t(2, "A", "SELL", D0 + timedelta(days=10), 10, 11000, net=9000, rate=9.0, exit_reason="익절") + perf = ta._compose_performance_v2([s], [buy], [D0.date()], None, 30) + assert "6-20d" in perf["by_holding_period"] + assert perf["roundtrip"]["matched_to_buy"] == 1 diff --git a/backend/tests/test_trade_executor_buy_ledger_r7_20261010.py b/backend/tests/test_trade_executor_buy_ledger_r7_20261010.py new file mode 100644 index 0000000..46e1664 --- /dev/null +++ b/backend/tests/test_trade_executor_buy_ledger_r7_20261010.py @@ -0,0 +1,148 @@ +"""[하드닝 R7 2026-10-10] 불성립·부분체결 확정 BUY 의 ActiveTrade 원복 회귀. + +운영 9/28: 스윙 BUY #324·#325 가 COMPLETED·전량인데 ActiveTrade 가 수십 초 만에 닫히고 +SELL·재시딩·보유가 없는 고아 장부. 0체결 소멸이면 TradeHistory 는 CANCELLED 로 강등되고 +주문 시점의 ActiveTrade 도 실제 체결 기준으로 원복돼야 한다. + +목은 trade_executor 가 실제로 참조하는 `te.korea_investment_api`(체결조회)와 db_worker 큐 종단에 +건다. 취소 API 는 예외 발생형(매수 미체결 주문은 취소하지 않는다는 4회차 정책 고정). +""" +from unittest.mock import AsyncMock + +import pytest + +from app.services import trade_executor as te + + +class _MustNotCall(BaseException): + pass + + +def _boom(name): + async def _f(*a, **k): + raise _MustNotCall(f"{name} 가 호출되면 안 된다") + return _f + + +async def _nosleep(*a, **k): + return None + + +def _exec(filled, remaining, found=True, price=70000): + return AsyncMock(return_value={"ok": True, "found": found, "filled_qty": filled, + "filled_price": price, "remaining_qty": remaining}) + + +@pytest.fixture +def env(monkeypatch): + import app.services.db_worker as dbw + cap = {"th": [], "close": [], "upsert": []} + + async def _upd(order_id, fields): + cap["th"].append((order_id, dict(fields))) + + async def _close(code, strategy_name=None): + cap["close"].append((code, strategy_name)) + + async def _ups(p): + cap["upsert"].append(dict(p)) + monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _upd) + monkeypatch.setattr(dbw, "enqueue_close_active_trade", _close) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _ups) + monkeypatch.setattr(te.asyncio, "sleep", _nosleep) + monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock(return_value=None)) + monkeypatch.setattr(te, "_notify_buy_ledger_v2", lambda msg: cap.setdefault("tg", []).append(msg)) + monkeypatch.setattr(te.korea_investment_api, "cancel_order_v2", _boom("cancel_order_v2(BUY)")) + te._buy_entry_rollbacks.clear() + yield cap + te._buy_entry_rollbacks.clear() + + +async def _verify(order_id="0009643", qty=2, order_type="BUY"): + await te._verify_and_sync_execution_v3( + order_id=order_id, stock_code="352820", stock_name="하이브", ordered_quantity=qty, + order_price=158488.0, order_type=order_type, is_domestic=True, exchange_code="KRX", + db_trade_id=0, delay=0, fill_entry_strategy_name="swing", + sell_pnl_basis={"strategy_type": "swing"}, + ) + + +@pytest.mark.asyncio +async def test_dead_zero_fill_new_buy_cancels_and_closes_active_trade(env, monkeypatch): + monkeypatch.setattr(te.korea_investment_api, "get_order_execution", _exec(0, 0)) + te.register_buy_entry_rollback_v2("0009643", "352820", "swing", 2, accumulate=False) + await _verify() + assert env["th"] == [("0009643", {"status": "CANCELLED", "executed_quantity": 0})] + assert env["close"] == [("352820", "swing")], "0체결 소멸인데 주문 시점 ActiveTrade 가 남았다" + assert env["upsert"] == [] + assert te._buy_entry_rollbacks == {}, "레지스트리 항목은 소비돼야 한다" + + +@pytest.mark.asyncio +async def test_dead_zero_fill_without_registration_keeps_legacy_behavior(env, monkeypatch): + """수동 매수 등 등록하지 않은 호출부는 ActiveTrade 를 건드리지 않는다(동작 불변).""" + monkeypatch.setattr(te.korea_investment_api, "get_order_execution", _exec(0, 0)) + await _verify() + assert env["th"] == [("0009643", {"status": "CANCELLED", "executed_quantity": 0})] + assert env["close"] == [] and env["upsert"] == [] + + +@pytest.mark.asyncio +async def test_dead_zero_fill_add_on_buy_requests_manual_check(env, monkeypatch, caplog): + monkeypatch.setattr(te.korea_investment_api, "get_order_execution", _exec(0, 0)) + te.register_buy_entry_rollback_v2("0009643", "352820", "swing", 2, accumulate=True) + with caplog.at_level("ERROR"): + await _verify() + assert env["th"][0][1]["status"] == "CANCELLED" + assert env["close"] == [] and env["upsert"] == [], "추가매수 누적 행을 닫으면 기존 포지션이 사라진다" + assert env.get("tg") and any("수동 확인" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_partial_fill_dead_remainder_resizes_before_fill_adjust(env, monkeypatch): + monkeypatch.setattr(te.korea_investment_api, "get_order_execution", _exec(3, 0, price=34900)) + te.register_buy_entry_rollback_v2("0009721", "352820", "swing", 11, accumulate=False) + await _verify(order_id="0009721", qty=11) + assert env["th"][-1][1]["status"] == "COMPLETED" and env["th"][-1][1]["executed_quantity"] == 3 + assert env["close"] == [] + assert env["upsert"][0] == {"stock_code": "352820", "strategy_name": "swing", "quantity": 3} + assert "entry_fill_adjust" in env["upsert"][1], "수량 정정이 체결가 보정보다 먼저여야 한다" + + +@pytest.mark.asyncio +async def test_live_unfilled_buy_is_not_cancelled_nor_rolled_back(env, monkeypatch, caplog): + """잔량이 살아 있는 매수는 취소·원복하지 않는다(나중에 체결될 수 있다) — 잠정 장부 warning.""" + monkeypatch.setattr(te.korea_investment_api, "get_order_execution", _exec(0, 2)) + te.register_buy_entry_rollback_v2("0009643", "352820", "swing", 2, accumulate=False) + with caplog.at_level("WARNING"): + await _verify() + assert env["th"] == [] and env["close"] == [] and env["upsert"] == [] + assert any("[BUY_LEDGER]" in r.message and "잠정" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_full_fill_does_not_roll_back(env, monkeypatch): + monkeypatch.setattr(te.korea_investment_api, "get_order_execution", _exec(2, 0)) + te.register_buy_entry_rollback_v2("0009643", "352820", "swing", 2, accumulate=False) + await _verify() + assert env["close"] == [] + assert all("quantity" not in u or "entry_fill_adjust" in u for u in env["upsert"]) + + +@pytest.mark.asyncio +async def test_registry_ignores_other_stock_and_sell(env, monkeypatch): + """주문번호는 일별 순번 — 다른 종목 키 충돌이면 원복하지 않는다. SELL 은 레지스트리를 보지 않는다.""" + monkeypatch.setattr(te.korea_investment_api, "get_order_execution", _exec(0, 0)) + te.register_buy_entry_rollback_v2("0009643", "006360", "swing", 2, accumulate=False) + await _verify() + assert env["close"] == [] + + +@pytest.mark.asyncio +async def test_execute_buy_registers_rollback_before_verify(monkeypatch): + """_execute_buy 가 성공 매수마다 원복 정보를 등록한다(소스 고정 — 체결 확인 spawn 직전).""" + import inspect + src = inspect.getsource(te._execute_buy) + i = src.index("register_buy_entry_rollback_v2(") + j = src.index("_verify_and_sync_execution_v3(", i) + assert i < j and "bool(holding)" in src[i:j] diff --git a/backend/tests/test_trade_executor_capital_guards_r6.py b/backend/tests/test_trade_executor_capital_guards_r6.py new file mode 100644 index 0000000..d54c9cc --- /dev/null +++ b/backend/tests/test_trade_executor_capital_guards_r6.py @@ -0,0 +1,106 @@ +"""하드닝 라운드 6 — 패턴메모리 증액 뒤 자본 가드 재적용 + DT 집계 예외 로깅. + +R6-1: 현금 유보(1-MAX_TOTAL_CAPITAL_USAGE)·스윙 상한(SWING_MAX_CAPITAL_RATIO)이 + 패턴메모리(최대 1.5배) 이전에만 적용돼 증액분이 두 상한을 넘을 수 있었다. +R6-2: 스윙 상한 계산의 DT 투자액 집계 예외가 로그 없이 삼켜졌다(§4). + +기대값은 settings 참조(§4.1). +""" +import ast +import inspect +import logging +import textwrap + +import pytest + +from app.core.config import settings +from app.services import trade_executor +from app.services.sizing_core import clamp_by_ratios + +TOTAL = 10_000_000.0 + + +def _swing_room(available_cash: float, dt_amount: float = 0.0) -> float: + invested = max(0.0, TOTAL - available_cash - dt_amount) + return TOTAL * settings.SWING_MAX_CAPITAL_RATIO - invested + + +def _pipeline(available_cash: float, base: float, uplift: float, dt_amount: float = 0.0) -> float: + """_execute_buy 의 가드 순서를 재현: 사전 가드 → 패턴메모리 → 4% → 재적용.""" + reserve = TOTAL * (1 - settings.MAX_TOTAL_CAPITAL_USAGE) + amt = base + if available_cash - amt < reserve: + amt = max(0, available_cash - reserve) + room = _swing_room(available_cash, dt_amount) + amt = min(amt, max(0.0, room)) + amt = amt * uplift # 패턴메모리 증액 + amt = clamp_by_ratios(amt, TOTAL, [settings.MAX_POSITION_SIZE], available_cash) + return trade_executor._reapply_capital_guards_v2(amt, available_cash, reserve, room) + + +def test_cash_reserve_holds_after_pattern_uplift(): + reserve = TOTAL * (1 - settings.MAX_TOTAL_CAPITAL_USAGE) + # 유보선 바로 위 현금 — 사전 가드가 금액을 유보 한도까지 줄인 상태 + available = reserve + TOTAL * settings.MAX_POSITION_SIZE * 0.5 + final = _pipeline(available, TOTAL * settings.MAX_POSITION_SIZE, 1.5) + assert available - final >= reserve - 1e-6 + + +def test_swing_cap_holds_after_pattern_uplift(): + # 스윙 투자액이 상한 바로 아래 — 잔여 한도가 4% 의 절반 + room_target = TOTAL * settings.MAX_POSITION_SIZE * 0.5 + available = TOTAL - (TOTAL * settings.SWING_MAX_CAPITAL_RATIO - room_target) + final = _pipeline(available, TOTAL * settings.MAX_POSITION_SIZE, 1.5) + assert final <= _swing_room(available) + 1e-6 + + +def test_reapply_none_swing_room_only_cash_reserve(): + out = trade_executor._reapply_capital_guards_v2(500_000.0, 1_000_000.0, 600_000.0, None) + assert out == pytest.approx(400_000.0) + + +def test_reapply_negative_bounds_return_zero(): + assert trade_executor._reapply_capital_guards_v2(100.0, 10.0, 50.0, -5.0) == 0.0 + + +def test_reapply_is_outside_try_after_pattern_memory(): + fn = ast.parse(textwrap.dedent(inspect.getsource(trade_executor._execute_buy))).body[0] + body = fn.body + pm_idx = next( + i for i, s in enumerate(body) + if isinstance(s, ast.Try) and "get_pattern_adjusted_invest_amount" in ast.unparse(s) + ) + assert "_reapply_capital_guards_v2" not in ast.unparse(body[pm_idx]) + after = [s for s in body[pm_idx + 1:] if not isinstance(s, ast.Try)] + assert any("_reapply_capital_guards_v2" in ast.unparse(s) for s in after) + + +class _ExplodingPositions: + def values(self): + raise RuntimeError("dt state broken") + + +def test_dt_amount_exception_logs_warning(monkeypatch, caplog): + from app.services.daytrading import position_manager + + monkeypatch.setattr(position_manager.day_trading_state, "positions", _ExplodingPositions()) + with caplog.at_level(logging.WARNING): + assert trade_executor._dt_invested_amount_v2("005930") == 0.0 + assert any("DT 투자액 집계 실패" in r.getMessage() for r in caplog.records) + assert all(r.levelno >= logging.WARNING for r in caplog.records if "DT 투자액" in r.getMessage()) + + +def test_dt_amount_sums_positions(monkeypatch): + from app.services.daytrading import position_manager + + monkeypatch.setattr( + position_manager.day_trading_state, "positions", + {"A": {"entry_price": 10_000, "quantity": 3}, "B": {"entry_price": "5000", "quantity": 2}}, + ) + assert trade_executor._dt_invested_amount_v2("X") == pytest.approx(40_000.0) + + +def test_execute_buy_has_no_inline_dt_silent_except(): + src = inspect.getsource(trade_executor._execute_buy) + assert "_dt_invested_amount_v2(" in src + assert "_dt_amount = 0.0" not in src diff --git a/backend/tests/test_trade_executor_entry_backfill_r3_20261009.py b/backend/tests/test_trade_executor_entry_backfill_r3_20261009.py new file mode 100644 index 0000000..8a34ba9 --- /dev/null +++ b/backend/tests/test_trade_executor_entry_backfill_r3_20261009.py @@ -0,0 +1,196 @@ +"""[하드닝 R3 2026-10-09] 기존 스윙 ActiveTrade.entry_price(주문가) → 체결가 1회성 백필. + +운영 사례(10/09 조회): ActiveTrade 진입가 / TradeHistory 체결가 + 032350 10,833 / 10,750, 082740 41,506 / 41,200, 009540 310,042 / 308,500, + 267250 191,050 / 190,200, 000720 113,263 / 113,000. 241560 은 BUY 가 레거시 미확인 규약. +""" +from datetime import datetime, timedelta +from types import SimpleNamespace as NS + +import pytest + +from app.services import trade_executor as te +from app.services.db_worker import _apply_entry_price_adjustments_v2, DT_STRATEGY_NAMES + +T0 = datetime(2026, 10, 8, 12, 47, 40) + + +def _at(**kw): + base = dict( + id=225, stock_code="082740", strategy_name="swing", status="active", quantity=9, + entry_price=41506.0, remaining_pct=1.0, highest_since_entry=41650.0, + entry_reason="평균회귀", currency_code="KRW", created_at=T0, + ) + base.update(kw) + return NS(**base) + + +def _buy(**kw): + base = dict( + id=448, stock_code="082740", order_type="BUY", status="COMPLETED", strategy_type="swing", + order_price=41506.0, executed_price=41200.0, executed_quantity=9, slippage=-0.74, + entry_reason="평균회귀", currency_code="KRW", created_at=T0 - timedelta(milliseconds=30), + ) + base.update(kw) + return NS(**base) + + +def _apply_like_worker(record, payload): + """db_worker._handle_upsert_active_trade 의 갱신 부분(조정 → 일반 setattr) 재현.""" + data = dict(payload) + skip = _apply_entry_price_adjustments_v2(record, data) + for k, v in data.items(): + if k in skip or k == "stock_code": + continue + if k in ("entry_fill_adjust", "strategy_name"): + continue + if v is not None: + setattr(record, k, v) + + +def test_single_buy_confirmed_backfills_to_fill_price_exactly(): + at = _at() + payload, why = te.plan_swing_entry_backfill_v2(at, [at], [_buy()]) + assert payload is not None, why + assert payload["strategy_name"] == "swing" + # 고점이 진입 후 갱신됐으면(41,650) 유지 + assert "highest_since_entry" not in payload + _apply_like_worker(at, payload) + assert at.entry_price == pytest.approx(41200.0) + assert at.highest_since_entry == pytest.approx(41650.0) + + +def test_highest_equal_to_order_price_is_lowered_too(): + at = _at(highest_since_entry=41506.0) + payload, _ = te.plan_swing_entry_backfill_v2(at, [at], [_buy()]) + assert payload["highest_since_entry"] == pytest.approx(41200.0) + _apply_like_worker(at, payload) + assert at.highest_since_entry == pytest.approx(41200.0) + + +def test_idempotent_second_plan_is_noop(): + at = _at() + payload, _ = te.plan_swing_entry_backfill_v2(at, [at], [_buy()]) + _apply_like_worker(at, payload) + again, why = te.plan_swing_entry_backfill_v2(at, [at], [_buy()]) + assert again is None and "이미" in why + + +def test_stop_reference_moves_to_actual_cost_basis(): + """손절 기준이 실제 평단 기준으로 돌아온다(값 자체는 settings 참조 — 파라미터 무변경).""" + pct = -abs(float(te.settings.STOP_LOSS_PERCENT)) + at = _at() + before = at.entry_price * (1 + pct / 100) + payload, _ = te.plan_swing_entry_backfill_v2(at, [at], [_buy()]) + _apply_like_worker(at, payload) + after = at.entry_price * (1 + pct / 100) + assert after < before + assert after == pytest.approx(41200.0 * (1 + pct / 100)) + + +def test_legacy_unconfirmed_buy_is_skipped(): + at = _at(stock_code="241560", entry_price=55576.0, quantity=7) + b = _buy(stock_code="241560", order_price=55576.0, executed_price=55576.0, + executed_quantity=7, slippage=None) + payload, why = te.plan_swing_entry_backfill_v2(at, [at], [b]) + assert payload is None and "미확정" in why + + +def test_null_executed_price_is_skipped(): + at = _at() + payload, why = te.plan_swing_entry_backfill_v2(at, [at], [_buy(executed_price=None)]) + assert payload is None and "미확정" in why + + +@pytest.mark.parametrize("name,reason", [("synced", "평균회귀"), ("swing", "동기화 유입 포지션")]) +def test_seeded_rows_are_skipped(name, reason): + at = _at(strategy_name=name, entry_reason=reason) + payload, why = te.plan_swing_entry_backfill_v2(at, [at], [_buy(entry_reason=reason)]) + assert payload is None and "시딩" in why + + +@pytest.mark.parametrize("dt_name", list(DT_STRATEGY_NAMES)) +def test_dt_rows_are_skipped(dt_name): + at = _at(strategy_name=dt_name) + assert te.plan_swing_entry_backfill_v2(at, [at], [_buy()])[0] is None + + +def test_dt_and_old_buys_are_not_matched(): + at = _at(quantity=3, entry_price=113263.0, stock_code="000720") + old = _buy(stock_code="000720", id=316, order_price=121303.0, executed_price=121303.0, + executed_quantity=3, created_at=T0 - timedelta(days=15)) + mine = _buy(stock_code="000720", id=409, order_price=113263.0, executed_price=113000.0, + executed_quantity=3) + dt = _buy(stock_code="000720", id=424, strategy_type="daytrading", order_price=112200.0, + executed_price=112100.0, executed_quantity=4, created_at=T0 + timedelta(days=1)) + payload, why = te.plan_swing_entry_backfill_v2(at, [at], [old, mine, dt]) + assert payload is not None, why + _apply_like_worker(at, payload) + assert at.entry_price == pytest.approx(113000.0) + + +def test_partial_fill_quantity_mismatch_is_skipped(): + at = _at() + payload, why = te.plan_swing_entry_backfill_v2(at, [at], [_buy(executed_quantity=5)]) + assert payload is None and "≠" in why + + +def test_multi_buy_weighted_average(): + at = _at(quantity=10, entry_price=(41506.0 * 6 + 40000.0 * 4) / 10) + b1 = _buy(executed_quantity=6, order_price=41506.0, executed_price=41200.0) + b2 = _buy(id=449, executed_quantity=4, order_price=40000.0, executed_price=39900.0, + created_at=T0 + timedelta(hours=1)) + payload, why = te.plan_swing_entry_backfill_v2(at, [at], [b1, b2]) + assert payload is not None, why + _apply_like_worker(at, payload) + assert at.entry_price == pytest.approx(round((41200.0 * 6 + 39900.0 * 4) / 10, 2)) + + +def test_multi_buy_after_partial_exit_is_skipped(): + at = _at(quantity=10, remaining_pct=0.5, entry_price=41000.0) + b1 = _buy(executed_quantity=6) + b2 = _buy(id=449, executed_quantity=4, created_at=T0 + timedelta(hours=1)) + assert te.plan_swing_entry_backfill_v2(at, [at], [b1, b2])[0] is None + + +def test_unexpected_current_entry_is_skipped(): + """주문가 기준도 체결가 기준도 아닌 진입가(수동 수정 등)는 건드리지 않는다.""" + at = _at(entry_price=40000.0) + payload, why = te.plan_swing_entry_backfill_v2(at, [at], [_buy()]) + assert payload is None and "수동" in why + + +def test_not_newest_row_of_strategy_is_skipped(): + at = _at() + newer = _at(id=999) + payload, why = te.plan_swing_entry_backfill_v2(at, [at, newer], [_buy()]) + assert payload is None and "최신" in why + + +def test_entry_reason_mismatch_is_skipped(): + at = _at(entry_reason="눌림목") + assert te.plan_swing_entry_backfill_v2(at, [at], [_buy()])[0] is None + + +@pytest.mark.asyncio +async def test_apply_submits_only_payloads_via_db_worker(monkeypatch): + sent = [] + + async def _rec(data): + sent.append(data) + + import app.services.db_worker as dbw + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _rec) + plans = [{"payload": {"stock_code": "A", "strategy_name": "swing", "entry_fill_adjust": {}}}, + {"payload": None, "reason": "skip"}] + n = await te.apply_swing_entry_backfill_v2(plans) + assert n == 1 and sent[0]["stock_code"] == "A" + + +@pytest.mark.asyncio +async def test_apply_propagates_submit_failure(): + async def _boom(_): + raise RuntimeError("submit failed") + + with pytest.raises(RuntimeError): + await te.apply_swing_entry_backfill_v2([{"payload": {"stock_code": "A"}}], submit=_boom) diff --git a/backend/tests/test_trade_executor_hardening_r1_20261009.py b/backend/tests/test_trade_executor_hardening_r1_20261009.py new file mode 100644 index 0000000..82ca9bf --- /dev/null +++ b/backend/tests/test_trade_executor_hardening_r1_20261009.py @@ -0,0 +1,216 @@ +"""[하드닝 R1 2026-10-09] trade_executor 회귀 테스트. + +1. 추천 경로 매도(_execute_sell) ↔ swing/exit_service 공용 가드 + - 공용 청산 쿨다운(exit_service._recent_exits / EXIT_COOLDOWN_SEC) + - 매도 직전 KIS 실보유 상한(min(주문가능, 보유) − DT) — 목은 두 경로의 공통 종단인 + exit_service._fetch_kis_holding_v2 에 건다. +2. 가격 필터 ETF/리츠 예외의 종목명 판정이 부분문자열이 아니다. +""" +import inspect +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import app.services.trade_executor as te +import app.services.swing.exit_service as es + +CODE = "005930" + + +class _Boom(Exception): + pass + + +def _boom(name): + async def _f(*a, **k): + raise _Boom(f"{name} 가 호출되면 안 된다") + return _f + + +class _Scal: + def first(self): + return None + + def all(self): + return [] + + +class _SellDB: + async def execute(self, stmt): + return SimpleNamespace(scalars=lambda: _Scal(), scalar_one_or_none=lambda: None) + + +@pytest.fixture +def env(monkeypatch): + """주문·DB 큐·체결확인을 격리. 주문은 기록만, 공용 쿨다운 딕셔너리는 테스트별 새 객체.""" + import app.services.db_worker as dbw + from app.services.loss_tracker import loss_tracker + from app.services.daytrading.position_manager import day_trading_state + + class _Orders(list): + result = {"rt_cd": "0", "output": {"ODNO": "0001", "ORD_TMD": "100000"}} + orders = _Orders() + + async def _po(is_domestic, stock_code, order_type, quantity, price, exchange_code="NASD", max_retries=3): + orders.append({"quantity": quantity, "price": price}) + return orders.result + monkeypatch.setattr(te, "_place_order_with_retry", _po) + monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True)) + monkeypatch.setattr(te, "_release_order_lock", AsyncMock(return_value=None)) + monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) + + async def _noop(*a, **k): + return None + for name in ("_verify_and_sync_execution", "_verify_and_sync_execution_v2", + "_verify_and_sync_execution_v3"): + if hasattr(te, name): + monkeypatch.setattr(te, name, _noop) + monkeypatch.setattr(dbw, "enqueue_trade_history", _noop) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _noop) + monkeypatch.setattr(dbw, "enqueue_close_active_trade", _noop) + monkeypatch.setattr(dbw, "enqueue_db_write", _noop) + monkeypatch.setattr(loss_tracker, "record_trade_result_safe", AsyncMock()) + monkeypatch.setattr(loss_tracker, "save_to_db", AsyncMock()) + # 시장가 경로(전량)만 쓰므로 시세 조회는 호출되면 안 된다 + monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price")) + monkeypatch.setattr(es, "_recent_exits", {}) + monkeypatch.setattr(es, "_presell_check_failures", {}) + monkeypatch.setattr(day_trading_state, "positions", {}) + return orders + + +def _kis(hldg, sellable): + async def _f(stock_code): + return (hldg, sellable) + return _f + + +async def _sell(sell_pct=1.0, qty=10, remaining_pct=1.0): + h = SimpleNamespace(quantity=qty, average_price=60000, profit_loss_rate=0, + strategy_type="swing", stock_name="삼성", current_price=70000) + stock = {"stock_code": CODE, "strategy_type": "swing", "strategy_name": "swing", + "remaining_pct": remaining_pct} + return await te._execute_sell(stock, _SellDB(), h, 70000, ["x"], True, "KRX", CODE, "삼성", + sell_pct=sell_pct) + + +# ── 1. 공용 청산 쿨다운 ───────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_recent_swing_exit_blocks_recommendation_sell(env, monkeypatch): + """exit_service 가 막 청산한 종목은 추천 경로가 같은 단계를 다시 팔지 않는다.""" + monkeypatch.setattr(es, "_fetch_kis_holding_v2", _boom("_fetch_kis_holding_v2")) + es._recent_exits[CODE] = time.time() - 5 + r = await _sell(0.5) + assert r["action"] == "SKIP" and r["reason"] == "recent_swing_exit_cooldown" + assert env == [] + + +@pytest.mark.asyncio +async def test_cooldown_expired_allows_sell(env, monkeypatch): + monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) + es._recent_exits[CODE] = time.time() - float(es.EXIT_COOLDOWN_SEC) - 1 + r = await _sell(1.0) + assert r["action"] == "SELL" and env and env[-1]["quantity"] == 10 + + +@pytest.mark.asyncio +async def test_successful_sell_marks_shared_cooldown(env, monkeypatch): + """추천 경로 매도 접수 → exit_service 도 같은 쿨다운을 본다.""" + monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) + before = time.time() + await _sell(1.0) + assert es._recent_exits.get(CODE, 0) >= before + + +@pytest.mark.asyncio +async def test_rejected_sell_does_not_mark_cooldown(env, monkeypatch): + """업무 거부(FAILED)는 체결이 없으므로 쿨다운에 기록하지 않는다(즉시 재시도 허용).""" + monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) + env.result = {"rt_cd": "1", "msg_cd": "APBK0000", "msg1": "거부"} + r = await _sell(1.0) + assert r.get("success") is False + assert CODE not in es._recent_exits + + +# ── 2. 매도 직전 KIS 실보유 상한 ────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_kis_cap_limits_stale_portfolio_quantity(env, monkeypatch): + """Portfolio 10주(낡음)인데 KIS 실보유 6주 중 DT 4주 → 스윙 매도는 2주까지만.""" + from app.services.daytrading.position_manager import day_trading_state + day_trading_state.positions[CODE] = {"quantity": 4} + monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(6, 6)) + r = await _sell(1.0, qty=10) + assert r["action"] == "SELL" + assert env[-1]["quantity"] == 2, "KIS 실보유 − DT 를 넘는 수량이 주문됐다(DT 물량 과다매도)" + + +@pytest.mark.asyncio +async def test_kis_cap_uses_orderable_quantity(env, monkeypatch): + """대기 매도 주문분(주문가능 < 보유)은 다시 팔지 않는다.""" + monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 3)) + await _sell(1.0, qty=10) + assert env[-1]["quantity"] == 3 + + +@pytest.mark.asyncio +async def test_kis_check_failure_holds_sell(env, monkeypatch): + async def _none(stock_code): + return None + monkeypatch.setattr(es, "_fetch_kis_holding_v2", _none) + r = await _sell(1.0) + assert r["action"] == "SKIP" and r["reason"] == "kis_holding_check_failed" + assert env == [] + + +@pytest.mark.asyncio +async def test_kis_sellable_zero_no_order(env, monkeypatch): + monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(0, 0)) + r = await _sell(0.5) + assert r["action"] == "SKIP" and r["reason"] == "kis_sellable_zero" + assert env == [] + + +@pytest.mark.asyncio +async def test_kis_cap_not_applied_when_holding_matches(env, monkeypatch): + """정상 상태(장부=KIS)에서는 분할매도 수량이 바뀌지 않는다 — 원본 대비 50% = 5주.""" + monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) + await _sell(0.5, qty=10) + assert env[-1]["quantity"] == 5 + + +def test_guards_are_outside_try(): + """쿨다운·KIS 상한 가드는 주문 try 블록보다 앞(§4: 가드를 try 안에 두지 말 것).""" + src = inspect.getsource(te._execute_sell) + i_try = src.index("recent_cutoff = _get_kst_now()") + assert src.index("_swing_exit_cooldown_block_v2(stock_code)") < i_try + assert src.index("_recommendation_sell_kis_cap_v2(") < i_try + + +# ── 3. 가격 필터 ETF/리츠 예외 판정 ───────────────────────────────────────────── + +@pytest.mark.parametrize("name", [ + "KODEX 200", "TIGER 미국S&P500", "SOL 미국배당다우존스", "ACE 미국S&P500", "HANARO 200", + "KBSTAR 200", "ARIRANG 고배당주", "KODEX200", "신한 인버스 2X 원유선물 ETN", + "SK리츠", "롯데리츠", "맥쿼리인프라", "SPDR S&P 500 ETF TRUST", "Realty Income REIT", +]) +def test_price_exempt_names(name): + assert te._is_price_filter_exempt_name_v2(name) is True + + +@pytest.mark.parametrize("name", [ + "인프라웨어", "NETFLIX", "SOLUM", "ACELYRIN", "ACEMAN", "리츠칼튼호텔", "에이스테크", "삼성전자", + "SOLID POWER", "", None, +]) +def test_non_exempt_names(name): + """부분문자열 일치로 일반주가 가격 필터(§5.1 >MIN_PRICE)를 우회하지 않는다.""" + assert te._is_price_filter_exempt_name_v2(name) is False + + +def test_price_filter_uses_positional_helper(): + src = inspect.getsource(te.execute_auto_trade) + assert "_is_etf = _is_price_filter_exempt_name_v2(stock_name)" in src + assert "_ETF_KEYWORDS" not in src diff --git a/backend/tests/test_trade_executor_hardening_r2_20261009.py b/backend/tests/test_trade_executor_hardening_r2_20261009.py new file mode 100644 index 0000000..61d86c9 --- /dev/null +++ b/backend/tests/test_trade_executor_hardening_r2_20261009.py @@ -0,0 +1,219 @@ +"""trade_executor 하드닝 루프 라운드 2 회귀 테스트 (2026-10-09). + +1. 추천 경로 check_exit_signal 블록: 트레일링 고점은 당일 고가(high_price)가 아니라 + 진입 이후 고점(highest_since_entry)이고, atr 이 없는 운영 추천 dict 에서는 휴면이다. +2. trade_executor 추적 백그라운드 태스크가 종료 시 lifespan 에서 drain 된다 + (main `_background_tasks` 와 같은 예산 안에서 동시에 — 종료 예산 불변). + +규약(CLAUDE.md §4.1): 목은 예외 발생형(BaseException 파생), 기대값은 settings.* 참조. +""" +import ast +import asyncio +import inspect +import textwrap +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.core.config import settings +from app.services import trade_executor + + +class _Reached(BaseException): + """경로 도달 신호 — `except Exception:` 에 삼켜지지 않게 BaseException 파생.""" + + +class _Result: + def __init__(self, rows=None): + self._rows = list(rows or []) + + def scalars(self): + return self + + def first(self): + return self._rows[0] if self._rows else None + + def scalar_one_or_none(self): + return self._rows[0] if self._rows else None + + def all(self): + return list(self._rows) + + +class _SellFlowDB: + """보유 조회 → holding, 보유 수 → [holding], 그 외 → 빈 결과. commit 은 금지.""" + + def __init__(self, holding): + self._seq = [[holding], [holding]] + self.n = 0 + + async def execute(self, stmt): + rows = self._seq[self.n] if self.n < len(self._seq) else [] + self.n += 1 + return _Result(rows) + + async def commit(self): + raise AssertionError("commit 금지 (§3.2)") + + +def _holding(): + return SimpleNamespace(quantity=10, strategy_type="swing", average_price=10_000.0, + profit_loss_rate=10.0, stock_name="테스트") + + +def _patch_flow(monkeypatch): + monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) + monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) + monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", False) + monkeypatch.setattr(settings, "NEWS_ENABLED", False) + captured = {} + + def _capture(**kw): + captured.update(kw) + raise _Reached("check_exit_signal reached") + + monkeypatch.setattr(trade_executor, "check_exit_signal", _capture) + monkeypatch.setattr(trade_executor, "_execute_sell", AsyncMock(side_effect=_Reached("sell reached"))) + monkeypatch.setattr(trade_executor, "_execute_buy", AsyncMock(side_effect=_Reached("buy reached"))) + return captured + + +# ── 1. 트레일링 고점 / 휴면 ────────────────────────────────────────────────── + +def test_trailing_high_ignores_intraday_high_price(): + stock = {"high_price": 20_000.0} + assert trade_executor._recommendation_trailing_high_v2(stock, 11_000.0) == 11_000.0 + + +def test_trailing_high_uses_highest_since_entry(): + stock = {"high_price": 20_000.0, "highest_since_entry": 12_500.0} + assert trade_executor._recommendation_trailing_high_v2(stock, 11_000.0) == 12_500.0 + # 현재가가 기존 고점보다 높으면 현재가로 상향(swing/exit_service 와 동일 정의) + assert trade_executor._recommendation_trailing_high_v2(stock, 13_000.0) == 13_000.0 + + +@pytest.mark.parametrize("bad", ["abc", float("nan"), float("inf")]) +def test_trailing_high_bad_value_falls_back_to_current(bad): + stock = {"highest_since_entry": bad, "high_price": 20_000.0} + assert trade_executor._recommendation_trailing_high_v2(stock, 11_000.0) == 11_000.0 + + +@pytest.mark.asyncio +async def test_exit_block_passes_post_entry_high_not_day_high(monkeypatch): + """atr 이 실린 dict 로 블록이 살아나도 trailing_high 는 당일 고가가 아니다.""" + captured = _patch_flow(monkeypatch) + stock = { + "stock_code": "005930", "stock_name": "테스트", "current_price": 11_000.0, + "signals": [], "strategy_type": "swing", "remaining_pct": 1.0, + "rsi": 56.0, "bb": (12_000.0, 10_500.0, 9_000.0), "atr": 200.0, + "high_price": 20_000.0, "highest_since_entry": 11_800.0, + } + with pytest.raises(_Reached, match="check_exit_signal"): + await trade_executor.execute_auto_trade(stock, _SellFlowDB(_holding())) + assert captured["trailing_high"] == 11_800.0 + assert captured["trailing_high"] != stock["high_price"] + + +@pytest.mark.asyncio +async def test_exit_block_dormant_for_recommendation_dict_without_atr(monkeypatch): + """운영 추천 dict(atr/bb/closes 없음)에서는 check_exit_signal 이 호출되지 않는다. + + 호출되면 _capture 가 _Reached 를 던져 테스트가 실패한다(휴면 계약 고정). + """ + _patch_flow(monkeypatch) + stock = { + "stock_code": "005930", "stock_name": "테스트", "current_price": 8_000.0, + "signals": [], "strategy_type": "swing", "remaining_pct": 1.0, "rsi": 25.0, + "high_price": 20_000.0, + } + res = await trade_executor.execute_auto_trade(stock, _SellFlowDB(_holding())) + assert isinstance(res, dict) + assert res.get("action") != "SELL" + + +def test_exit_block_no_longer_reads_high_price(): + src = inspect.getsource(trade_executor.execute_auto_trade) + assert 'stock.get("high_price") or current_price' not in src + assert "_recommendation_trailing_high_v2" in src + + +# ── 2. 종료 시 추적 태스크 drain 배선 ──────────────────────────────────────── + +def _lifespan_tree(): + from app import main + return ast.parse(textwrap.dedent(inspect.getsource(main.lifespan))) + + +def test_lifespan_background_step_drains_tracked_tasks(): + from app import main + tree = _lifespan_tree() + calls = [ + n for n in ast.walk(tree) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) + and n.func.id == "_drain_all_background_tasks_v2" + ] + assert calls, "lifespan 이 _drain_all_background_tasks_v2 를 호출하지 않는다" + drain_src = inspect.getsource(main._drain_all_background_tasks_v2) + assert "drain_tracked_tasks_v2" in drain_src + assert "_await_background_tasks" in drain_src + + +def test_drain_runs_before_db_worker_stops(): + """종료 튜플에서 '__background__' 가 'db_worker_task' 보다 앞에 있다.""" + tree = _lifespan_tree() + for n in ast.walk(tree): + if isinstance(n, ast.Tuple): + names = [e.value for e in n.elts if isinstance(e, ast.Constant)] + if "__background__" in names and "db_worker_task" in names: + assert names.index("__background__") < names.index("db_worker_task") + assert names[-1] == "db_worker_task" + return + pytest.fail("종료 태스크 튜플을 찾지 못했다") + + +@pytest.mark.asyncio +async def test_drain_all_shares_one_budget_and_cancels_both(monkeypatch): + from app import main + + async def _slow(): + await asyncio.sleep(3600) + + t_main = asyncio.get_running_loop().create_task(_slow()) + main._background_tasks.add(t_main) + t_main.add_done_callback(main._background_tasks.discard) + t_tracked = trade_executor.spawn_tracked_task_v2(_slow(), name="verify-fill") + + budget = 0.2 + t0 = time.monotonic() + await main._drain_all_background_tasks_v2(budget) + elapsed = time.monotonic() - t0 + # 직렬이면 ≥ 2×budget. 동시 drain 이므로 한 예산 + 여유 안에 끝난다. + assert elapsed < budget * 1.8 + assert t_main.cancelled() + assert t_tracked.cancelled() + await asyncio.sleep(0) + assert t_tracked not in trade_executor._tracked_bg_tasks + + +@pytest.mark.asyncio +async def test_drain_all_failure_is_logged_not_raised(monkeypatch, caplog): + from app import main + + class _Boom(Exception): + pass + + async def _boom(timeout): + raise _Boom("drain failed") + + monkeypatch.setattr(trade_executor, "drain_tracked_tasks_v2", _boom) + with caplog.at_level("WARNING", logger=main.logger.name): + await main._drain_all_background_tasks_v2(0.01) + assert any("drain 실패" in r.getMessage() for r in caplog.records) + + +def test_shutdown_budget_unchanged_by_tracked_drain(): + """동시 drain 이므로 종료 예산 공식(uvicorn + 백그라운드 + db drain)은 그대로 유예 미만.""" + from app import main + assert main.check_shutdown_budget() == [] diff --git a/backend/tests/test_trade_executor_levels_tasks_slippage_r1_20261009.py b/backend/tests/test_trade_executor_levels_tasks_slippage_r1_20261009.py new file mode 100644 index 0000000..7747f21 --- /dev/null +++ b/backend/tests/test_trade_executor_levels_tasks_slippage_r1_20261009.py @@ -0,0 +1,221 @@ +"""trade_executor 하드닝 루프 라운드 1 회귀 테스트 (2026-10-09) — 레벨·백그라운드 태스크·슬리피지. + +1/3. ActiveTrade.stop_loss/take_profit 에 퍼센트(-1.50/3.00) 대신 절대가 저장 +2/4. 백그라운드 태스크(체결 확인·미체결 취소·sync·텔레그램) 강참조 보관 + drain +5. 슬리피지 side 보정 비용(bp, 불리=+) 헬퍼 + +규약(CLAUDE.md §4.1): 기대값은 settings.* 참조, 목은 예외 발생형. +""" +import ast +import asyncio +import inspect +import logging + +import pytest + +from app.core.config import settings +from app.services import trade_executor +from app.services import mean_reversion_strategy + + +# ── 1/3. ActiveTrade 손절·익절 절대가 ───────────────────────────────────────── + +@pytest.fixture +def _resolver_config(monkeypatch): + """해석기를 config 값으로 고정(적응형/DB 오버레이 배제).""" + def _fake(atr, close): + return -abs(float(settings.STOP_LOSS_PERCENT)), float(settings.ATR_STOP_MULTIPLIER) + monkeypatch.setattr(mean_reversion_strategy, "resolve_swing_stop_params_v2", _fake) + + +def test_levels_without_atr_use_effective_pct_stop(_resolver_config): + entry = 10_000.0 + stop, tp = trade_executor._swing_active_trade_levels_v2(entry, None, 3.0) + expected = round(entry * (1 - abs(float(settings.STOP_LOSS_PERCENT)) / 100), 2) + assert stop == expected + assert 0 < stop < entry + assert tp == round(entry * 1.03, 2) + + +def test_levels_pick_tighter_price_between_atr_and_pct(_resolver_config): + entry = 10_000.0 + mult = float(settings.ATR_STOP_MULTIPLIER) + pct_stop = entry * (1 - abs(float(settings.STOP_LOSS_PERCENT)) / 100) + # ATR 손절이 pct 손절보다 높도록(타이트) 작은 ATR + small_atr = (entry - pct_stop) / mult / 2 + stop, _ = trade_executor._swing_active_trade_levels_v2(entry, small_atr, None) + atr_stop = entry - mult * small_atr + assert stop == round(max(atr_stop, pct_stop), 2) == round(atr_stop, 2) + # ATR 이 커서 ATR 손절이 더 낮으면 pct 손절(높은 쪽) + big_atr = (entry - pct_stop) / mult * 2 + stop2, _ = trade_executor._swing_active_trade_levels_v2(entry, big_atr, None) + assert stop2 == round(pct_stop, 2) + + +def test_levels_sign_guard_on_positive_pct(monkeypatch): + monkeypatch.setattr( + mean_reversion_strategy, "resolve_swing_stop_params_v2", + lambda atr, close: (abs(float(settings.STOP_LOSS_PERCENT)), float(settings.ATR_STOP_MULTIPLIER)), + ) + stop, _ = trade_executor._swing_active_trade_levels_v2(10_000.0, None, None) + assert stop is not None and stop < 10_000.0 + + +class _ResolverBoom(Exception): + pass + + +def test_levels_resolver_exception_falls_back_to_config(monkeypatch, caplog): + def _boom(atr, close): + raise _ResolverBoom("x") + monkeypatch.setattr(mean_reversion_strategy, "resolve_swing_stop_params_v2", _boom) + with caplog.at_level(logging.WARNING, logger=trade_executor.logger.name): + stop, tp = trade_executor._swing_active_trade_levels_v2(20_000.0, 0, 5.0) + assert stop == round(20_000.0 * (1 - abs(float(settings.STOP_LOSS_PERCENT)) / 100), 2) + assert tp == round(20_000.0 * 1.05, 2) + assert any("손절 파라미터 해석 실패" in r.getMessage() for r in caplog.records) + + +@pytest.mark.parametrize("entry", [0, -1, None, "abc"]) +def test_levels_invalid_entry_returns_none(entry, _resolver_config): + assert trade_executor._swing_active_trade_levels_v2(entry, 100, 3.0) == (None, None) + + +def test_levels_never_store_percent_values(_resolver_config): + """추천 actions 기본값(-1.5/3.0)이 그대로 저장되던 결함 — 결과는 진입가 근처의 가격이어야 한다.""" + entry = 63_000.0 + stop, tp = trade_executor._swing_active_trade_levels_v2(entry, None, 3.0) + assert stop > entry * 0.5 and tp > entry + + +def test_buy_active_trade_payload_uses_absolute_levels(): + """_execute_buy 의 ActiveTrade upsert 가 stock 의 퍼센트 값을 직접 넣지 않는다(소스 고정).""" + tree = ast.parse(inspect.getsource(trade_executor)) + found = False + for node in ast.walk(tree): + if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "enqueue_active_trade_upsert": + if not node.args or not isinstance(node.args[0], ast.Dict): + continue + d = node.args[0] + keys = [k.value if isinstance(k, ast.Constant) else None for k in d.keys] + if "accumulate_entry" not in keys: + continue + vals = dict(zip(keys, d.values)) + assert isinstance(vals["stop_loss"], ast.Name) and vals["stop_loss"].id == "_at_stop_price" + assert isinstance(vals["take_profit"], ast.Name) and vals["take_profit"].id == "_at_tp_price" + found = True + assert found + + +# ── 2/4. 백그라운드 태스크 강참조 ───────────────────────────────────────────── + +def test_no_bare_create_task_in_trade_executor(): + """반환값을 버리는 asyncio.create_task 호출이 남아 있지 않다(spawn_tracked_task_v2 경유).""" + tree = ast.parse(inspect.getsource(trade_executor)) + offenders = [] + for fn in ast.walk(tree): + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if fn.name == "spawn_tracked_task_v2": + continue + for node in ast.walk(fn): + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr in ("create_task", "ensure_future")): + offenders.append((fn.name, node.lineno)) + assert offenders == [] + + +@pytest.mark.asyncio +async def test_spawn_tracked_keeps_strong_ref_and_discards_on_done(): + gate = asyncio.Event() + + async def _work(): + await gate.wait() + return 1 + + task = trade_executor.spawn_tracked_task_v2(_work(), name="t1") + assert task in trade_executor._tracked_bg_tasks + assert trade_executor.pending_tracked_tasks_count_v2() >= 1 + gate.set() + await task + await asyncio.sleep(0) + assert task not in trade_executor._tracked_bg_tasks + + +class _TaskBoom(Exception): + pass + + +@pytest.mark.asyncio +async def test_spawn_tracked_logs_exception_at_warning(caplog): + async def _fail(): + raise _TaskBoom("verify failed") + + with caplog.at_level(logging.DEBUG, logger=trade_executor.logger.name): + task = trade_executor.spawn_tracked_task_v2(_fail(), name="boom") + with pytest.raises(_TaskBoom): + await task + await asyncio.sleep(0) + recs = [r for r in caplog.records if "백그라운드 태스크 예외" in r.getMessage()] + assert recs and all(r.levelno >= logging.WARNING for r in recs) + assert task not in trade_executor._tracked_bg_tasks + + +@pytest.mark.asyncio +async def test_drain_waits_then_cancels_remaining(): + done_flag = [] + + async def _fast(): + await asyncio.sleep(0) + done_flag.append(1) + + async def _slow(): + await asyncio.sleep(3600) + + t_fast = trade_executor.spawn_tracked_task_v2(_fast()) + t_slow = trade_executor.spawn_tracked_task_v2(_slow()) + cancelled = await trade_executor.drain_tracked_tasks_v2(0.05) + assert done_flag == [1] + assert cancelled >= 1 + assert t_slow.cancelled() + assert t_fast.done() and not t_fast.cancelled() + await asyncio.sleep(0) + assert t_slow not in trade_executor._tracked_bg_tasks + + +@pytest.mark.asyncio +async def test_drain_with_nothing_pending_returns_zero(): + assert await trade_executor.drain_tracked_tasks_v2(0.01) == 0 + + +# ── 5. 슬리피지 side 보정 ──────────────────────────────────────────────────── + +def test_slippage_cost_sign_by_side(): + # DT SELL id446: 주문 28500 / 체결 28533 → 원시 + = 매도자에게 유리 → 비용 음수 + raw_sell = (28533 - 28500) / 28500 * 100 + assert trade_executor.slippage_cost_bp_v2("SELL", raw_sell) == pytest.approx(-raw_sell * 100) + assert trade_executor.slippage_cost_bp_v2("SELL", raw_sell) < 0 + # DT BUY id441: 주문 28625 / 체결 28647 → 원시 + = 매수자에게 불리 → 비용 양수 + raw_buy = (28647 - 28625) / 28625 * 100 + assert trade_executor.slippage_cost_bp_v2("buy", raw_buy) == pytest.approx(raw_buy * 100) + assert trade_executor.slippage_cost_bp_v2("BUY", raw_buy) > 0 + + +def test_slippage_cost_mixed_average_not_cancelled(): + """BUY/SELL 모두 불리한 체결이면 보정 비용 평균은 양수(원시값 평균은 상쇄돼 0).""" + raw = [0.10, -0.10] # BUY +0.10%(불리), SELL -0.10%(불리) + sides = ["BUY", "SELL"] + assert sum(raw) / 2 == 0 + costs = [trade_executor.slippage_cost_bp_v2(s, r) for s, r in zip(sides, raw)] + assert sum(costs) / 2 == pytest.approx(10.0) + + +@pytest.mark.parametrize("side,val", [("HOLD", 0.1), ("BUY", None), ("SELL", "x"), ("BUY", float("nan"))]) +def test_slippage_cost_invalid_returns_none(side, val): + assert trade_executor.slippage_cost_bp_v2(side, val) is None + + +def test_stored_slippage_convention_unchanged(): + """저장 규약(side 무관 원시 %)은 유지 — DT 와 같은 컬럼 공유(한쪽만 바꾸면 혼재).""" + f = trade_executor._fill_update_fields_v2("SELL", 1, 28533.0, 0.1158, None) + assert float(f["slippage"]) == pytest.approx(0.1158) diff --git a/backend/tests/test_trade_executor_net_loss_r1_20261009.py b/backend/tests/test_trade_executor_net_loss_r1_20261009.py new file mode 100644 index 0000000..e651f7e --- /dev/null +++ b/backend/tests/test_trade_executor_net_loss_r1_20261009.py @@ -0,0 +1,180 @@ +"""[하드닝 R1 2026-10-09] 추천 경로(_execute_sell) 전량 청산의 loss_tracker 승패 = 비용 차감 순손익. + +이전: `is_loss = profit_loss_rate < 0` (gross 수익률) → 왕복 비용 이내 소폭 이익(+0.1%)이 '승'. +swing/exit_service 는 `_position_is_loss_v2(이전 레그 순손익 합, 이번 레그 순손익)` 이라 +3연패 밴이 경로별로 다른 기준으로 집계됐다(CLAUDE.md §5.1). 두 경로가 같은 정의를 쓰는지 고정한다. +""" +import inspect +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import app.services.trade_executor as te +import app.services.swing.exit_service as es +from app.core.config import settings +from app.services.trade_helpers import calculate_trade_costs + +CODE = "005930" +AVG = 70000.0 + + +class _Boom(Exception): + pass + + +def _boom(name): + async def _f(*a, **k): + raise _Boom(f"{name} 가 호출되면 안 된다") + return _f + + +class _Scal: + def __init__(self, row=None): + self._row = row + + def first(self): + return self._row + + def all(self): + return [self._row] if self._row is not None else [] + + +class _SellDB: + """ActiveTrade SELECT 에만 active 행을 돌려준다(중복매도 체크 등은 빈 결과).""" + + def __init__(self, at_row=None): + self.at_row = at_row + + async def execute(self, stmt): + row = self.at_row if "active_trades" in str(stmt) else None + return SimpleNamespace(scalars=lambda: _Scal(row), scalar_one_or_none=lambda: None) + + +@pytest.fixture +def env(monkeypatch): + import app.services.db_worker as dbw + from app.services.loss_tracker import loss_tracker + from app.services.daytrading.position_manager import day_trading_state + + orders = [] + + async def _po(is_domestic, stock_code, order_type, quantity, price, exchange_code="NASD", max_retries=3): + orders.append({"quantity": quantity, "price": price}) + return {"rt_cd": "0", "output": {"ODNO": "0001", "ORD_TMD": "100000"}} + monkeypatch.setattr(te, "_place_order_with_retry", _po) + monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True)) + monkeypatch.setattr(te, "_release_order_lock", AsyncMock(return_value=None)) + monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) + + async def _noop(*a, **k): + return None + for name in ("_verify_and_sync_execution", "_verify_and_sync_execution_v2", + "_verify_and_sync_execution_v3"): + if hasattr(te, name): + monkeypatch.setattr(te, name, _noop) + for name in ("enqueue_trade_history", "enqueue_active_trade_upsert", + "enqueue_close_active_trade", "enqueue_db_write"): + monkeypatch.setattr(dbw, name, _noop) + rec = AsyncMock() + monkeypatch.setattr(loss_tracker, "record_trade_result_safe", rec) + monkeypatch.setattr(loss_tracker, "save_to_db", AsyncMock()) + monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price")) + monkeypatch.setattr(es, "_recent_exits", {}) + monkeypatch.setattr(es, "_presell_check_failures", {}) + monkeypatch.setattr(day_trading_state, "positions", {}) + + async def _kis(stock_code): + return (10, 10) + monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis) + return SimpleNamespace(orders=orders, rec=rec) + + +async def _sell(price, db=None, sell_pct=1.0, qty=10): + h = SimpleNamespace(quantity=qty, average_price=AVG, profit_loss_rate=None, + strategy_type="swing", stock_name="삼성", current_price=price) + stock = {"stock_code": CODE, "strategy_type": "swing", "strategy_name": "swing", + "remaining_pct": 1.0, "market": "KOSPI"} + return await te._execute_sell(stock, db or _SellDB(), h, price, ["x"], True, "KRX", CODE, "삼성", + sell_pct=sell_pct) + + +def _recorded_is_loss(rec): + assert rec.await_count == 1 + return rec.await_args.kwargs["is_loss"] + + +def _leg_net(price, qty=10): + c = calculate_trade_costs(entry_price=AVG, exit_price=price, quantity=qty, + is_domestic=True, market="KOSPI") + return (price - AVG) * qty - c["total_cost"] + + +@pytest.mark.asyncio +async def test_small_gross_gain_within_costs_is_loss(env): + """+0.1% gross 이익은 왕복 비용(settings 수수료×2 + 매도세) 이내 → 순손익 음수 → 패.""" + price = AVG * 1.001 + assert (price - AVG) > 0 and _leg_net(price) < 0 # 전제: settings 비용이 0.1% 보다 크다 + r = await _sell(price) + assert r["action"] == "SELL" + assert _recorded_is_loss(env.rec) is True + + +@pytest.mark.asyncio +async def test_clear_gain_is_win(env): + price = AVG * 1.05 + assert _leg_net(price) > 0 + await _sell(price) + assert _recorded_is_loss(env.rec) is False + + +@pytest.mark.asyncio +async def test_prior_partial_gains_offset_small_final_loss(env, monkeypatch): + """1차 익절(+) 후 잔량 소폭 손실 청산 → 포지션 누적 순손익 양수면 승(exit_service 와 동일).""" + at_row = SimpleNamespace(id=1, stock_code=CODE, strategy_name="MEAN_REVERSION", status="active") + seen = {} + + async def _prior(db, stock_code, active_trade, market): + seen["row"] = active_trade + seen["orders_at_call"] = len(env.orders) + return 50000.0 + monkeypatch.setattr(es, "_prior_position_realized_net_v2", _prior) + price = AVG * 0.999 + assert _leg_net(price) < 0 and _leg_net(price) + 50000.0 > 0 + await _sell(price, db=_SellDB(at_row)) + assert seen["row"] is at_row + assert seen["orders_at_call"] == 0 # 주문 전에 조회(이번 레그 이중 합산 방지) + assert _recorded_is_loss(env.rec) is False + + +@pytest.mark.asyncio +async def test_prior_fetch_failure_falls_back_to_leg_net(env, monkeypatch, caplog): + at_row = SimpleNamespace(id=1, stock_code=CODE, strategy_name="MEAN_REVERSION", status="active") + monkeypatch.setattr(es, "_prior_position_realized_net_v2", _boom("_prior_position_realized_net_v2")) + price = AVG * 1.001 + with caplog.at_level("WARNING"): + await _sell(price, db=_SellDB(at_row)) + assert _recorded_is_loss(env.rec) is True + assert any("누적 실현손익 조회 실패" in r.getMessage() for r in caplog.records) + + +@pytest.mark.asyncio +async def test_partial_sell_does_not_query_prior_nor_record(env, monkeypatch): + monkeypatch.setattr(te, "_recommendation_prior_position_net_v2", _boom("prior")) + monkeypatch.setattr(te, "_fresh_domestic_price_v2", AsyncMock(return_value=AVG * 1.05)) + await _sell(AVG * 1.05, sell_pct=0.5, qty=10) + assert env.rec.await_count == 0 + + +@pytest.mark.parametrize("prior,leg", [ + (None, -1.0), (None, 0.0), (None, 1.0), (100.0, -50.0), (-100.0, 50.0), + (0.0, -0.01), (5000.0, -410.0), (-1.0, 0.5), +]) +def test_same_definition_as_swing_exit_service(prior, leg): + assert te._recommendation_position_is_loss_v2(prior, leg) == es._position_is_loss_v2(prior, leg) + + +def test_no_gross_rate_loss_judgment_left(): + src = inspect.getsource(te._execute_sell) + assert "is_loss = profit_loss_rate < 0" not in src + assert "_recommendation_position_is_loss_v2(_prior_realized_net, profit_loss)" in src diff --git a/backend/tests/test_trade_executor_risk_fixes_20261001.py b/backend/tests/test_trade_executor_risk_fixes_20261001.py index 4f6e4e1..7d0c6ad 100644 --- a/backend/tests/test_trade_executor_risk_fixes_20261001.py +++ b/backend/tests/test_trade_executor_risk_fixes_20261001.py @@ -488,8 +488,16 @@ async def test_sell_skips_non_swing_holdings(st, reason, _block_order_gate, _emp @pytest.mark.asyncio @pytest.mark.parametrize("st", ["swing", None]) -async def test_sell_allows_swing_holdings(st, _block_order_gate, _empty_dt_state): - """스윙(또는 legacy NULL) 행은 가드를 통과해 주문 관문까지 간다.""" +async def test_sell_allows_swing_holdings(st, _block_order_gate, _empty_dt_state, monkeypatch): + """스윙(또는 legacy NULL) 행은 가드를 통과해 주문 관문까지 간다. + + [2026-10-09] 매도 직전 KIS 실보유 상한 조회(_recommendation_sell_kis_cap_v2)가 주문 락보다 + 앞에 생겨 그 지점도 '소유권 가드 통과 후 주문 직전 관문'으로 본다(예외 발생형). + """ + monkeypatch.setattr( + trade_executor, "_recommendation_sell_kis_cap_v2", + AsyncMock(side_effect=_Reached("kis cap gate reached")), + ) with pytest.raises(_Reached): await _call_sell(_holding(st)) diff --git a/backend/tests/test_trade_executor_sellday_unknown_buy_r1_20261009.py b/backend/tests/test_trade_executor_sellday_unknown_buy_r1_20261009.py new file mode 100644 index 0000000..2b6bf89 --- /dev/null +++ b/backend/tests/test_trade_executor_sellday_unknown_buy_r1_20261009.py @@ -0,0 +1,324 @@ +"""[하드닝 R1 2026-10-09] trade_executor — 당일 매도 판정(executed_at NULL) / 결과 불명 BUY 정정. + +1) swing/exit_service·DT·MDD 의 SELL 행은 COMPLETED + executed_at NULL 이다(운영 14일 전수). + _today_sell_conditions_v2 가 executed_at 만 보면 손절 직후 당일 재매수·부분매도 반복이 허용됐다. +2) 결과 불명 BUY 는 PENDING 으로만 남고 확정 태스크가 없었다(§8.5-34). +""" +import asyncio +import datetime as _dt +import inspect + +import pytest +from sqlalchemy import create_engine, text, select +from sqlalchemy.dialects import postgresql +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession + +from app.core.config import settings +from app.models.models import TradeHistory +from app.services import trade_executor + + +# ── 공통: trade_history 최소 스키마(sqlite) ──────────────────────────────────── +_DDL = """ +CREATE TABLE trade_history ( + id INTEGER PRIMARY KEY, + stock_code VARCHAR(20) NOT NULL, + order_type VARCHAR(20) NOT NULL, + status VARCHAR(50) NOT NULL, + strategy_type VARCHAR(20), + created_at DATETIME, + executed_at DATETIME +) +""" + +_TODAY = _dt.datetime(2026, 10, 8, 0, 0, 0) + + +def _insert(conn, rid, status, created, executed, order_type="SELL", strategy="swing", code="015760"): + conn.execute( + text("INSERT INTO trade_history (id, stock_code, order_type, status, strategy_type, " + "created_at, executed_at) VALUES (:i, :c, :o, :s, :st, :ca, :ea)"), + {"i": rid, "c": code, "o": order_type, "s": status, "st": strategy, + "ca": created, "ea": executed}, + ) + + +def _ids(rows_spec): + eng = create_engine("sqlite://") + with eng.begin() as conn: + conn.execute(text(_DDL)) + for spec in rows_spec: + _insert(conn, *spec) + stmt = select(TradeHistory.id).where(*trade_executor._today_sell_conditions_v2("015760", _TODAY)) + return sorted(r[0] for r in conn.execute(stmt)) + + +def test_swing_exit_sell_with_null_executed_at_counts_as_sold_today(): + # 운영 id447 형태: 스윙 손절 SELL, COMPLETED, executed_at NULL, 당일 12:47 생성 + assert _ids([(447, "COMPLETED", _dt.datetime(2026, 10, 8, 12, 47, 8), None)]) == [447] + + +def test_null_executed_at_from_yesterday_is_not_today(): + assert _ids([(1, "COMPLETED", _dt.datetime(2026, 10, 7, 14, 0), None)]) == [] + + +def test_executed_at_takes_precedence_over_created_at(): + # 어제 생성·오늘 체결 → 당일, 오늘 생성·어제 체결(비정상) → executed_at 우선이라 제외 + assert _ids([ + (1, "COMPLETED", _dt.datetime(2026, 10, 7, 15, 0), _dt.datetime(2026, 10, 8, 9, 1)), + (2, "COMPLETED", _dt.datetime(2026, 10, 8, 9, 0), _dt.datetime(2026, 10, 7, 9, 0)), + ]) == [1] + + +def test_pending_and_failed_rules_unchanged(): + assert _ids([ + (1, "PENDING", _dt.datetime(2026, 10, 8, 10, 0), None), + (2, "PENDING", _dt.datetime(2026, 10, 7, 10, 0), None), + (3, "FAILED", _dt.datetime(2026, 10, 8, 10, 0), None), + (4, "COMPLETED", _dt.datetime(2026, 10, 8, 10, 0), None, "BUY"), + ]) == [1] + + +@pytest.mark.asyncio +async def test_non_dt_sold_today_sees_swing_exit_null_executed_at_but_not_dt(): + eng = create_async_engine("sqlite+aiosqlite://") + async with eng.begin() as conn: + await conn.execute(text(_DDL)) + async with AsyncSession(eng) as db: + await db.execute( + text("INSERT INTO trade_history (id, stock_code, order_type, status, strategy_type, " + "created_at, executed_at) VALUES (1, '015760', 'SELL', 'COMPLETED', 'daytrading', " + ":ca, NULL)"), {"ca": _dt.datetime(2026, 10, 8, 10, 0)}) + await db.commit() + # DT 매도만 있으면 스윙 부분매도 반복 차단은 걸리지 않는다(기존 정책) + assert await trade_executor._non_dt_sold_today_v2(db, "015760", _TODAY) is False + await db.execute( + text("INSERT INTO trade_history (id, stock_code, order_type, status, strategy_type, " + "created_at, executed_at) VALUES (2, '015760', 'SELL', 'COMPLETED', 'swing', " + ":ca, NULL)"), {"ca": _dt.datetime(2026, 10, 8, 12, 47)}) + await db.commit() + assert await trade_executor._non_dt_sold_today_v2(db, "015760", _TODAY) is True + await eng.dispose() + + +def test_kelly_recent_trades_ordered_by_coalesce(): + src = inspect.getsource(trade_executor._calculate_kelly_position) + assert "_fill_or_created_at_v2()" in src + assert "desc(TradeHistory.executed_at)" not in src + sql = str(select(TradeHistory.id).order_by(trade_executor._fill_or_created_at_v2().desc()) + .compile(dialect=postgresql.dialect())) + assert "coalesce(trade_history.executed_at, trade_history.created_at) DESC" in sql + + +# ── 2) 결과 불명 BUY 정정 ───────────────────────────────────────────────────── + +class _Calls: + def __init__(self): + self.th_updates = [] + self.at_upserts = [] + self.notes = [] + self.syncs = 0 + + +@pytest.fixture +def unknown_env(monkeypatch): + calls = _Calls() + monkeypatch.setattr(trade_executor, "_UNKNOWN_BUY_RESOLVE_DELAYS_SEC", (0, 0, 0)) + + async def _upd(order_id, fields): + calls.th_updates.append((order_id, dict(fields))) + + async def _ups(payload): + calls.at_upserts.append(dict(payload)) + + async def _sync(delay=0): + calls.syncs += 1 + + import app.services.db_worker as dbw + monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _upd) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _ups) + monkeypatch.setattr(trade_executor, "_delayed_sync_portfolio", _sync) + monkeypatch.setattr(trade_executor, "_notify_order_unknown_v2", lambda m: calls.notes.append(m)) + # 손절가 계산은 config 폴백 경로로 고정(적응형·DB 미사용) + import app.services.mean_reversion_strategy as mrs + + class _NoResolver(Exception): + pass + + def _boom(*a, **k): + raise _NoResolver("resolver disabled in test") + monkeypatch.setattr(mrs, "resolve_swing_stop_params_v2", _boom) + return calls + + +class _ExecBoom(Exception): + pass + + +def _fake_kis(monkeypatch, info=None, raise_exec=False): + class _K: + async def get_order_execution(self, order_id, is_domestic=True): + if raise_exec: + raise _ExecBoom("exec lookup must not be called") + return dict(info or {}) + monkeypatch.setattr(trade_executor, "korea_investment_api", _K()) + + +def _ctx(**over): + base = { + "placeholder_id": "UNK-B015760-104500000000", + "stock_code": "015760", + "stock_name": "한국전력", + "quantity": 10, + "order_price": 20100.0, + "sent_at": _dt.datetime(2026, 10, 9, 10, 45, 0), + "is_domestic": True, + "holding": False, + "strategy_type": "swing", + "strategy_name": "swing", + "entry_reason": "기술: RSI 과매도", + "atr": None, + "take_profit": 5.0, + } + base.update(over) + return base + + +def _order_row(odno="0000012345", tmd="104502", qty=10, side="02", code="015760"): + return {"pdno": code, "sll_buy_dvsn_cd": side, "ord_qty": str(qty), "ord_tmd": tmd, "odno": odno} + + +@pytest.mark.asyncio +async def test_unknown_buy_not_in_orders_becomes_failed(monkeypatch, unknown_env): + _fake_kis(monkeypatch, raise_exec=True) + + async def _lookup(code): + return [_order_row(side="01")] # 같은 종목 매도 주문뿐 + monkeypatch.setattr(trade_executor, "_lookup_todays_orders_strict_v2", _lookup) + trade_executor._note_cycle_buy_v2("015760", "한국전력", 10, 20100.0) + + assert await trade_executor._resolve_unknown_buy_v2(_ctx()) == "failed" + assert unknown_env.th_updates == [("UNK-B015760-104500000000", { + "status": "FAILED", "executed_quantity": 0, "order_type": "BUY", "strategy_type": "swing", + })] + assert unknown_env.at_upserts == [] + assert "015760" not in trade_executor._recent_cycle_buys_v2() + assert unknown_env.syncs == 1 + + +@pytest.mark.asyncio +async def test_unknown_buy_lookup_failure_keeps_pending(monkeypatch, unknown_env): + _fake_kis(monkeypatch, raise_exec=True) + attempts = [] + + async def _lookup(code): + attempts.append(code) + return None + monkeypatch.setattr(trade_executor, "_lookup_todays_orders_strict_v2", _lookup) + + assert await trade_executor._resolve_unknown_buy_v2(_ctx()) == "unresolved" + assert len(attempts) == len(trade_executor._UNKNOWN_BUY_RESOLVE_DELAYS_SEC) + assert unknown_env.th_updates == [] and unknown_env.at_upserts == [] + assert unknown_env.notes # 수동 확인 통지 + + +@pytest.mark.asyncio +async def test_unknown_buy_found_and_filled_registers_active_trade(monkeypatch, unknown_env): + _fake_kis(monkeypatch, info={"ok": True, "found": True, "filled_qty": 10, + "filled_price": 20050.0, "remaining_qty": 0}) + seq = [[], [_order_row()]] # 첫 조회는 아직 미반영 → 두 번째에 발견 + + async def _lookup(code): + return seq.pop(0) if seq else [_order_row()] + monkeypatch.setattr(trade_executor, "_lookup_todays_orders_strict_v2", _lookup) + + assert await trade_executor._resolve_unknown_buy_v2(_ctx()) == "accepted" + pid, fields = unknown_env.th_updates[0] + assert pid == "UNK-B015760-104500000000" + assert fields["status"] == "COMPLETED" + assert fields["executed_quantity"] == 10 + assert float(fields["executed_price"]) == 20050.0 + assert fields["executed_at"] is not None + at = unknown_env.at_upserts[0] + assert at["strategy_name"] == "swing" and at["quantity"] == 10 + assert at["entry_price"] == 20050.0 + # 절대가 규약 — config 손절률(음수 부호 가드)로 계산된 가격, 퍼센트 저장 금지 + expected_stop = round(20050.0 * (1 - abs(float(settings.STOP_LOSS_PERCENT)) / 100), 2) + assert at["stop_loss"] == expected_stop + assert 0 < at["stop_loss"] < at["entry_price"] < at["take_profit"] + + +@pytest.mark.asyncio +async def test_unknown_buy_accepted_but_unfilled_uses_order_qty(monkeypatch, unknown_env): + _fake_kis(monkeypatch, info={"ok": True, "found": True, "filled_qty": 0, + "filled_price": 0, "remaining_qty": 10}) + + async def _lookup(code): + return [_order_row()] + monkeypatch.setattr(trade_executor, "_lookup_todays_orders_strict_v2", _lookup) + + assert await trade_executor._resolve_unknown_buy_v2(_ctx(holding=True)) == "accepted" + _, fields = unknown_env.th_updates[0] + assert fields["status"] == "COMPLETED" and fields["executed_quantity"] == 10 + assert "executed_price" not in fields # 체결가 모르면 주문가로 대체하지 않음 + at = unknown_env.at_upserts[0] + assert at["accumulate_entry"] is True and at["entry_price"] == 20100.0 + + +@pytest.mark.asyncio +async def test_unknown_buy_accepted_zero_fill_dead_becomes_cancelled(monkeypatch, unknown_env): + _fake_kis(monkeypatch, info={"ok": True, "found": True, "filled_qty": 0, + "filled_price": 0, "remaining_qty": 0, "cancelled": True}) + + async def _lookup(code): + return [_order_row()] + monkeypatch.setattr(trade_executor, "_lookup_todays_orders_strict_v2", _lookup) + + assert await trade_executor._resolve_unknown_buy_v2(_ctx()) == "cancelled" + assert unknown_env.th_updates[0][1]["status"] == "CANCELLED" + assert unknown_env.at_upserts == [] + + +def test_match_rejects_orders_before_send_time_and_other_qty(): + sent = _dt.datetime(2026, 10, 9, 10, 45, 0) + slack = trade_executor._UNKNOWN_BUY_ORDER_TIME_SLACK_SEC + early = (sent - _dt.timedelta(seconds=slack + 5)).strftime("%H%M%S") + rows = [_order_row(odno="1", tmd=early), _order_row(odno="2", qty=11), + _order_row(odno="3", tmd="1045xx"), _order_row(odno="4", tmd="104503")] + m = trade_executor._match_unknown_buy_order_v2(rows, "015760", 10, sent) + assert m["odno"] == "4" + assert trade_executor._match_unknown_buy_order_v2(rows[:3], "015760", 10, sent) is None + + +@pytest.mark.asyncio +async def test_overseas_unknown_buy_stays_pending(monkeypatch, unknown_env): + _fake_kis(monkeypatch, raise_exec=True) + + async def _lookup(code): + raise AssertionError("해외는 국내 주문내역 조회를 하지 않는다") + monkeypatch.setattr(trade_executor, "_lookup_todays_orders_strict_v2", _lookup) + assert await trade_executor._resolve_unknown_buy_v2(_ctx(is_domestic=False, stock_code="AAPL")) == "unresolved" + assert unknown_env.th_updates == [] + + +def test_placeholder_order_id_shape(): + pid = trade_executor._unknown_order_placeholder_id_v2("015760", "BUY") + assert pid.startswith(trade_executor._UNKNOWN_BUY_ORDER_ID_PREFIX + "B015760-") + assert len(pid) <= TradeHistory.__table__.c.order_id.type.length + + +def test_buy_path_spawns_resolver_outside_success_block(): + """결과 불명 분기에서 resolver 를 띄우고, 그 호출이 `if success:` 안에 있지 않다.""" + import ast + src = inspect.getsource(trade_executor) + tree = ast.parse(src) + found = False + for node in ast.walk(tree): + if isinstance(node, ast.If) and isinstance(node.test, ast.Name) and node.test.id == "_outcome_unknown": + body_src = ast.unparse(node) + if "_resolve_unknown_buy_v2(" in body_src: + found = True + assert found + for node in ast.walk(tree): + if isinstance(node, ast.If) and isinstance(node.test, ast.Name) and node.test.id == "success": + assert "_resolve_unknown_buy_v2(" not in ast.unparse(node) diff --git a/backend/tests/test_trade_executor_volume_floor_r5_20261009.py b/backend/tests/test_trade_executor_volume_floor_r5_20261009.py new file mode 100644 index 0000000..3530e8b --- /dev/null +++ b/backend/tests/test_trade_executor_volume_floor_r5_20261009.py @@ -0,0 +1,151 @@ +"""trade_executor 하드닝 루프 라운드 5 회귀 테스트 (2026-10-09). + +결함: rule_id 종목은 BLOCK_KEYWORDS 만 없으면 should_buy 를 강제했는데, +market_analyzer 의 '(거래량부족-차단)' 은 BLOCK_KEYWORDS 에 없어 거래량 하한을 무시하고 +매수했다(운영 9/28 이후 스윙 BUY 25건 중 8건, 화면 buy_allowed=False 와 불일치). + +규약(CLAUDE.md §4.1): 목은 예외 발생형(BaseException 파생), 기대값은 settings.* 참조. +""" +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.core.config import settings +from app.services import trade_executor + + +class _Reached(BaseException): + """경로 도달 신호 — `except Exception:` 에 삼켜지지 않게 BaseException 파생.""" + + +class _Result: + def __init__(self, rows=None): + self._rows = list(rows or []) + + def scalars(self): + return self + + def first(self): + return self._rows[0] if self._rows else None + + def scalar_one_or_none(self): + return self._rows[0] if self._rows else None + + def scalar(self): + return self._rows[0] if self._rows else 0 + + def all(self): + return list(self._rows) + + +class _DB: + """첫 조회(보유) → holding, 보유 수 → [holding], 그 외 빈 결과. commit 금지.""" + + def __init__(self, holding=None): + self._seq = [[holding] if holding else [], [holding] if holding else []] + self.n = 0 + + async def execute(self, stmt): + rows = self._seq[self.n] if self.n < len(self._seq) else [] + self.n += 1 + return _Result(rows) + + async def commit(self): + raise AssertionError("commit 금지 (§3.2)") + + +def _patch(monkeypatch, ml_enabled=False): + monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) + monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) + monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", ml_enabled) + monkeypatch.setattr(settings, "NEWS_ENABLED", False) + monkeypatch.setattr(trade_executor, "_execute_buy", AsyncMock(side_effect=_Reached("buy reached"))) + monkeypatch.setattr(trade_executor, "_execute_sell", AsyncMock(side_effect=_Reached("sell reached"))) + + +_PRICE = float(getattr(settings, "MIN_PRICE_KRW", 5000)) * 4 + +_VOL_BLOCK_SIGNALS = [ + "RSI 바닥 탈출 반등 (34.0) (거래량부족-차단)", + "스토캐스틱 과매도 (%K: 16.2)", + "RSI+스토캐스틱 복합 과매도", +] + + +def _stock(signals, rule_id=7, **extra): + d = { + "stock_code": "005490", "stock_name": "테스트", "current_price": _PRICE, + "signals": list(signals), "score": 1.5, "rule_id": rule_id, "strategy_type": "swing", + } + d.update(extra) + return d + + +# ── 헬퍼 ──────────────────────────────────────────────────────────────────── + +def test_helper_detects_marker(): + assert trade_executor._volume_floor_block_reason_v2(_VOL_BLOCK_SIGNALS) == _VOL_BLOCK_SIGNALS[0] + + +@pytest.mark.parametrize("sigs", [None, [], ["RSI 바닥 탈출 반등 (34.0) + 거래량증가"], + ["RSI 바닥 탈출 반등 (34.0) (거래량보통)"]]) +def test_helper_none_without_marker(sigs): + assert trade_executor._volume_floor_block_reason_v2(sigs) is None + + +def test_marker_matches_market_analyzer_producer(): + """생산자 문자열이 바뀌면 차단이 조용히 사라지므로 소스 대조로 고정한다.""" + import inspect + from app.services import market_analyzer + assert trade_executor._VOLUME_FLOOR_BLOCK_MARKER in inspect.getsource(market_analyzer) + + +# ── 실행 경로 ──────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_rule_stock_with_volume_block_does_not_buy(monkeypatch): + """운영 id 327 재현: rule_id 강제매수가 거래량부족-차단을 덮어쓰지 않는다.""" + _patch(monkeypatch) + res = await trade_executor.execute_auto_trade(_stock(_VOL_BLOCK_SIGNALS), _DB()) + assert isinstance(res, dict) + assert res.get("action") != "BUY" + + +@pytest.mark.asyncio +async def test_rule_stock_without_volume_block_still_buys(monkeypatch): + """대조군: 같은 신호에서 마커만 빼면 rule 강제매수 경로가 _execute_buy 에 도달한다.""" + _patch(monkeypatch) + sigs = ["RSI 바닥 탈출 반등 (34.0) + 거래량증가"] + _VOL_BLOCK_SIGNALS[1:] + with pytest.raises(_Reached, match="buy reached"): + await trade_executor.execute_auto_trade(_stock(sigs), _DB()) + + +@pytest.mark.asyncio +async def test_non_rule_strong_signal_with_volume_block_does_not_buy(monkeypatch): + """운영 id 350 재현: 비규칙 + 볼린저 하한선 터치(강신호)여도 거래량 미달이면 매수 안 함.""" + _patch(monkeypatch) + sigs = ["RSI 바닥 탈출 반등 (18.8→19.7) + BB하단 (거래량부족-차단)", + "볼린저 하한선 터치", "스토캐스틱 과매도 (%K: 11.9)"] + res = await trade_executor.execute_auto_trade(_stock(sigs, rule_id=None, score=3.0), _DB()) + assert res.get("action") != "BUY" + + +@pytest.mark.asyncio +async def test_ml_buy_does_not_override_volume_block(monkeypatch): + _patch(monkeypatch, ml_enabled=True) + monkeypatch.setattr(trade_executor, "_ml_gate_score_v2", lambda a, b: 1e9) + st = _stock(_VOL_BLOCK_SIGNALS, ml_score=99.0, ml_signal="BUY", ml_confidence=0.99) + res = await trade_executor.execute_auto_trade(st, _DB()) + assert res.get("action") != "BUY" + + +@pytest.mark.asyncio +async def test_volume_block_does_not_block_exit(monkeypatch): + """§8.1-11: 거래량 차단은 매수만 끈다 — 보유 종목 매도 신호는 그대로 청산된다.""" + _patch(monkeypatch) + holding = SimpleNamespace(quantity=10, strategy_type="swing", average_price=_PRICE, + profit_loss_rate=-3.0, stock_name="테스트") + sigs = _VOL_BLOCK_SIGNALS + ["데드크로스"] + with pytest.raises(_Reached, match="sell reached"): + await trade_executor.execute_auto_trade(_stock(sigs, remaining_pct=1.0), _DB(holding)) diff --git a/backend/tests/test_trade_helpers_surge_trend_20261009.py b/backend/tests/test_trade_helpers_surge_trend_20261009.py new file mode 100644 index 0000000..9e88bf2 --- /dev/null +++ b/backend/tests/test_trade_helpers_surge_trend_20261009.py @@ -0,0 +1,61 @@ +"""trade_helpers 추격매수/트렌드 필터 — KIS 일봉 순서(최신 우선) 회귀 테스트.""" +import asyncio +from datetime import timedelta +from unittest.mock import AsyncMock + +from app.services import trade_helpers as th +from app.core.timeutil import now_kst + + +def _bars(n, today_included=True, last=100.0, old=80.0): + """KIS 순서(최신 우선) n봉. 최근 3봉은 last, 이전은 old.""" + base = now_kst() - timedelta(days=0 if today_included else 1) + rows = [] + for i in range(n): + d = (base - timedelta(days=i)).strftime("%Y%m%d") + px = last if i < 4 else old + rows.append({"stck_bsop_date": d, "stck_clpr": str(px), + "stck_hgpr": str(px * 1.01), "stck_lwpr": str(px * 0.99)}) + return rows + + +def _api(**kw): + api = type("A", (), {})() + for k, v in kw.items(): + setattr(api, k, v) + return api + + +def test_surge_blocks_recent_rally(monkeypatch): + monkeypatch.setattr(th, "korea_investment_api", + _api(get_daily_chart=AsyncMock(return_value={"output2": _bars(30)}))) + assert asyncio.run(th.check_surge_prevention("005930", True, "", 120.0))["skip"] is True + + +def test_surge_ignores_month_old_rally(monkeypatch): + monkeypatch.setattr(th, "korea_investment_api", + _api(get_daily_chart=AsyncMock(return_value={"output2": _bars(30)}))) + assert asyncio.run(th.check_surge_prevention("005930", True, "", 101.0)) == {"skip": False} + + +def test_surge_today_bar_absent(monkeypatch): + monkeypatch.setattr(th, "korea_investment_api", + _api(get_daily_chart=AsyncMock(return_value={"output2": _bars(30, False)}))) + assert asyncio.run(th.check_surge_prevention("005930", True, "", 101.0)) == {"skip": False} + assert asyncio.run(th.check_surge_prevention("005930", True, "", 120.0))["skip"] is True + + +def test_trend_filter_no_data_passes(monkeypatch): + monkeypatch.setattr(th, "TREND_FILTER_ENABLED", True) + monkeypatch.setattr(th, "korea_investment_api", + _api(get_daily_chart=AsyncMock(return_value={"output": _bars(30)}))) + r = asyncio.run(th.check_trend_filter("005930", "x", True, "", 100.0, {"ma20": 100.0})) + assert r == {"skip": False} + + +def test_trend_filter_uses_stock_ma60_no_kis_call(monkeypatch): + monkeypatch.setattr(th, "TREND_FILTER_ENABLED", True) + monkeypatch.setattr(th, "korea_investment_api", + _api(get_daily_chart=AsyncMock(side_effect=AssertionError("KIS 호출 금지")))) + r = asyncio.run(th.check_trend_filter("005930", "x", True, "", 100.0, {"ma20": 90.0, "ma60": 100.0})) + assert r["skip"] is True diff --git a/backend/tests/test_trades_delete_pending_keeps_pending_r7.py b/backend/tests/test_trades_delete_pending_keeps_pending_r7.py new file mode 100644 index 0000000..69c988d --- /dev/null +++ b/backend/tests/test_trades_delete_pending_keeps_pending_r7.py @@ -0,0 +1,54 @@ +"""R7: DELETE /trades/pending(+v2) 는 PENDING 을 지우지 않는다 (원장 BUY 합계·강등 방어 근거).""" +import pytest +from types import SimpleNamespace + + +class _Res: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return SimpleNamespace(all=lambda: self._rows) + + +class _DB: + def __init__(self): + self.stmts, self.deleted = [], [] + + async def execute(self, stmt): + self.stmts.append(stmt) + return _Res([]) + + async def delete(self, obj): + self.deleted.append(obj) + + async def commit(self): + return None + + +def _params(stmt): + return [str(v) for v in stmt.compile().params.values()] + + +@pytest.mark.asyncio +async def test_v1_only_failed_before_today(): + from app.api.endpoints import trades as tr + db = _DB() + await tr.delete_pending_trades(db=db) + p = _params(db.stmts[0]) + assert "FAILED" in p and "PENDING" not in p + assert "created_at <" in str(db.stmts[0].compile()) + + +@pytest.mark.asyncio +async def test_v2_only_failed_before_today(monkeypatch): + from app.api.endpoints import trades_v2 as t2 + + async def _uid(*a, **k): + return None + monkeypatch.setattr(t2, "_resolve_target_user_id", _uid) + db = _DB() + await t2.delete_pending_trades_v2(user=SimpleNamespace(), db=db) + p = _params(db.stmts[0]) + assert "FAILED" in p and "PENDING" not in p + assert "created_at <" in str(db.stmts[0].compile()) diff --git a/backend/tests/test_us_entry_ban_gate.py b/backend/tests/test_us_entry_ban_gate.py new file mode 100644 index 0000000..595c9d8 --- /dev/null +++ b/backend/tests/test_us_entry_ban_gate.py @@ -0,0 +1,56 @@ +"""미국 매수 경로 loss_tracker 밴 게이트 회귀 (§5.1).""" +import pytest +from unittest.mock import AsyncMock + +from app.services import us_market_scheduler as ums + + +class _Order(Exception): + pass + + +def _setup(monkeypatch, banned): + monkeypatch.setattr(ums, "_get_us_account_info", AsyncMock(return_value={ + "exchange_rate": 1400.0, "total_assets_krw": 100_000_000.0, + "available_cash_krw": 50_000_000.0, "current_us_allocation_krw": 0.0})) + monkeypatch.setattr(ums, "_monitor_us_exits", AsyncMock(return_value=[])) + monkeypatch.setattr(ums, "_can_enter_new_position", lambda: True) + monkeypatch.setattr(ums.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) + monkeypatch.setattr(ums, "get_us_watchlist", lambda: ["AAPL"]) + monkeypatch.setattr(ums, "scan_us_stocks", AsyncMock(return_value=[ + {"ticker": "AAPL", "price": 100.0, "rsi": 30.0, "description": "t"}])) + monkeypatch.setattr(ums, "calculate_us_position_size", lambda **k: 5_000_000.0) + # 공통 종단(주문)은 예외 발생형 — 밴 게이트가 뚫리면 여기서 터진다 + async def _boom(*a, **k): + raise _Order("order reached") + monkeypatch.setattr("app.services.trade_executor._place_order_with_retry", _boom) + if isinstance(banned, Exception): + monkeypatch.setattr(ums.loss_tracker, "is_banned_safe", AsyncMock(side_effect=banned)) + else: + monkeypatch.setattr(ums.loss_tracker, "is_banned_safe", AsyncMock(return_value=banned)) + + +@pytest.mark.asyncio +async def test_banned_ticker_not_ordered(monkeypatch): + _setup(monkeypatch, True) + r = await ums.us_trade_cycle() + assert r["trades"] == [] + + +@pytest.mark.asyncio +async def test_ban_check_failure_is_fail_closed(monkeypatch): + _setup(monkeypatch, RuntimeError("db down")) + r = await ums.us_trade_cycle() + assert r["trades"] == [] + + +@pytest.mark.asyncio +async def test_unbanned_reaches_order(monkeypatch): + _setup(monkeypatch, False) + called = [] + async def _rec(*a, **k): + called.append(1) + raise _Order("x") + monkeypatch.setattr("app.services.trade_executor._place_order_with_retry", _rec) + await ums.us_trade_cycle() + assert called diff --git a/backend/tests/test_walk_forward_engine_v2_20261009.py b/backend/tests/test_walk_forward_engine_v2_20261009.py new file mode 100644 index 0000000..2eca702 --- /dev/null +++ b/backend/tests/test_walk_forward_engine_v2_20261009.py @@ -0,0 +1,82 @@ +"""walk_forward: 실전 청산 모델(kr_backtest_engine)을 돌리고, 엔진이 소비하는 키만 탐색한다.""" +import asyncio +from datetime import date + +import pytest + +from app.core.config import settings +from app.services import walk_forward as wf +from app.services.kr_backtest_engine import ( + BacktestMetrics, BacktestResult, KrBacktestEngine, MeanReversionBacktest, +) + + +class _Trial: + def __init__(self): + self.suggested = [] + + def suggest_int(self, name, lo, hi): + self.suggested.append(name) + return lo + + def suggest_float(self, name, lo, hi, step=None): + self.suggested.append(name) + return lo + + +def _cfg(**kw): + return wf.WalkForwardConfig(stock_codes=["005930"], start_date=date(2025, 1, 1), + end_date=date(2025, 12, 31), **kw) + + +@pytest.fixture(autouse=True) +def _block_toy_engine(monkeypatch): + """구 장난감 엔진 호출은 즉시 예외 (거짓 통과 방지).""" + import app.services.backtest_engine as toy + + def boom(*a, **k): + raise AssertionError("장난감 backtest_engine 사용") + monkeypatch.setattr(toy.BacktestEngine, "run_backtest", boom) + + +def test_search_space_only_engine_consumed_keys(): + t = _Trial() + params = wf.WalkForwardOptimizer()._build_params_from_trial_v2(t) + consumed = set(MeanReversionBacktest().default_params()) + assert set(params) <= consumed, set(params) - consumed + assert "MEAN_REVERSION_RSI_THRESHOLD" not in params + assert params["stop_loss_pct"] > 0 + + +def test_run_uses_kr_engine_and_normalizes_units(monkeypatch): + seen = {} + + async def fake_run(self, **kw): + seen.update(kw) + return BacktestResult(metrics=BacktestMetrics( + total_return_pct=10.0, max_drawdown_pct=-5.0, win_rate=60.0, + sharpe_ratio=1.2, total_trades=7)) + monkeypatch.setattr(KrBacktestEngine, "run", fake_run) + + out = asyncio.run(wf.WalkForwardOptimizer()._run_single_backtest_v2( + ["005930"], date(2025, 1, 1), date(2025, 6, 1), + {"bb_period": 20}, _cfg(position_size=0.5, max_positions=99))) + assert out["mdd_pct"] == pytest.approx(-0.05) + assert out["win_rate"] == pytest.approx(0.6) + assert out["total_return_pct"] == pytest.approx(0.10) + assert out["total_trades"] == 7 + assert seen["params"]["position_size_pct"] <= settings.MAX_POSITION_SIZE + assert seen["params"]["max_positions"] <= settings.MAX_HOLDINGS_COUNT + + +def test_default_params_from_settings(): + d = wf.WalkForwardOptimizer._default_params_v2() + assert d["stop_loss_pct"] == abs(settings.STOP_LOSS_PERCENT) + assert d["atr_stop_multiplier"] == settings.ATR_STOP_MULTIPLIER + + +def test_recommended_params_keys(): + wr = wf.WindowResult(0, "a", "b", "c", "d", {"bb_period": 18, "atr_stop_multiplier": 2.0, + "stop_loss_pct": 5.0}, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, False) + rec = wf.WalkForwardOptimizer()._compute_recommended_params([wr]) + assert rec == {"bb_period": 18, "atr_stop_multiplier": 2.0, "stop_loss_pct": 5.0} diff --git a/docker-compose.yml b/docker-compose.yml index 7a01cd7..a809a37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -135,6 +135,18 @@ services: condition: service_healthy mcp-server: condition: service_started + # [R2 M] 프로세스 밖 생존 감시: 이벤트 루프 hang / uvicorn 무응답을 컨테이너 상태(unhealthy)로 노출. + # 판정은 "10초 내 HTTP 200 응답"만 본다 — /health 의 status=degraded(DB 순간 장애·장외 stale)로 + # unhealthy 가 되면 오탐이 잦다. degraded/schedulers.stale 감시는 앱 밖 cron 의 몫: + # scripts/health_watch.py (평일 장중 5분 cron, 연속 이상 시 텔레그램) + .claude/scripts/prod_smoke.sh 의 /health 확인. + # 위 cron 대신 compose 사이드카 health-watch 가 같은 스크립트를 돌린다(NAS cron 등록 불필요). + # (plain compose 는 unhealthy 컨테이너를 재시작하지 않는다 — 경보·수동 확인용 표식) + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=10).status == 200 else 1)"] + interval: 60s + timeout: 15s + retries: 3 + start_period: 120s restart: unless-stopped deploy: resources: @@ -165,6 +177,45 @@ services: condition: service_healthy restart: unless-stopped + # /health 능동 감시 사이드카 — NAS cron 등록 여부에 의존하지 않도록 compose 안에서 직접 돈다. + # 평일(KST) 09~15시 5분 주기로 scripts/health_watch.py 실행(상태 전이 시 텔레그램). 장외는 stale 오탐 방지로 건너뜀. + # 상태 파일은 named volume 에 유지(재기동해도 연속 이상 카운트 보존). + # 마지막 실행 시각: `docker exec autostock-health-watch cat /state/last_run` (배포 스모크에서 mtime 확인용). + health-watch: + image: python:3.11-slim + container_name: autostock-health-watch + environment: + - TZ=Asia/Seoul + - HEALTH_URL=http://backend:8000/health + - HEALTH_WATCH_STATE=/state/health_watch.json + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} + - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} + volumes: + - ./scripts/health_watch.py:/app/health_watch.py:ro + - health_watch_state:/state + command: + - sh + - -c + - | + while true; do + k=$$(date -u -d '+9 hours' '+%u %H') + dow=$${k% *}; hh=$${k#* } + if [ "$$dow" -le 5 ] && [ "$$((10#$$hh))" -ge 9 ] && [ "$$((10#$$hh))" -le 15 ]; then + python /app/health_watch.py || true + date -u '+%Y-%m-%dT%H:%M:%SZ' > /state/last_run + fi + sleep 300 + done + networks: + - autostock-network + depends_on: + - backend + restart: unless-stopped + deploy: + resources: + limits: + memory: 64M + # Frontend frontend: build: @@ -193,6 +244,7 @@ volumes: redis_data: rabbitmq_data: backup_data: + health_watch_state: networks: autostock-network: diff --git a/frontend/src/components/ChartPanel.tsx b/frontend/src/components/ChartPanel.tsx index 2201f3f..db18fa0 100644 --- a/frontend/src/components/ChartPanel.tsx +++ b/frontend/src/components/ChartPanel.tsx @@ -6,8 +6,8 @@ * - AiAnalysisPanel: 선택 종목의 AI 투자 분석 텍스트 표시 * * 최적화: - * - React.memo + 커스텀 비교 함수로 livePrice 변경 시 리렌더 방지 - * (livePrice는 TradingChart 내부에서 ref 기반으로 처리) + * - React.memo + 커스텀 비교 함수 — livePrice 는 통과시켜 TradingChart 가 마지막 캔들만 update + * (차트 재생성 없음, livePrices 는 초당 최대 1회 flush) * - 차트 로딩 중에도 기존 차트를 유지하여 깜빡임 방지 */ import React from 'react' @@ -29,16 +29,20 @@ interface ChartPanelProps { onPeriodChange: (period: 'D' | 'W' | 'M') => void /** 차트 헤더에 표시할 종목명 */ stockName: string - /** 실시간 현재가 (TradingChart에서 ref 기반 처리) */ + /** 실시간 현재가 — chartData 가 선택 종목의 데이터일 때만 전달할 것 (Dashboard 책임) */ livePrice?: number + /** 당일 누적 거래량 (일봉 오늘 캔들 반영용) */ + liveVolume?: number + /** 차트 데이터가 없을 때 표시할 사유 (샘플 데이터 거부·해외 차트 미지원·오류 등) */ + emptyReason?: string | null } /** * ChartPanel — 종목 캔들 차트 패널 * * React.memo 커스텀 비교 함수: - * - livePrice 변경은 무시 (TradingChart가 ref 기반 500ms polling으로 직접 처리) - * - chartData, chartLoading, selectedStock, chartPeriod 등 변경 시에만 리렌더 + * - chartData, chartLoading, selectedStock, chartPeriod, livePrice/liveVolume 등 변경 시 리렌더 + * - TradingChart 는 livePrice 변경 시 series.update 만 수행 (차트 재생성 없음) */ const ChartPanel = React.memo(({ chartData, @@ -48,6 +52,8 @@ const ChartPanel = React.memo(({ onPeriodChange, stockName, livePrice, + liveVolume, + emptyReason, }: ChartPanelProps) => { return (
@@ -85,10 +91,19 @@ const ChartPanel = React.memo(({ {/* Chart area — 로딩 중에도 기존 차트 유지 (깜빡임 방지) */}
{chartData && chartData.length > 0 ? ( - + ) : !chartLoading ? ( -
- 데이터 없음 +
+ 차트 데이터 없음 + {emptyReason && ( + ({emptyReason}) + )}
) : null} {chartLoading && ( @@ -103,14 +118,16 @@ const ChartPanel = React.memo(({
) }, (prev, next) => { - // livePrice 변경은 TradingChart 내부에서 ref 기반 처리 — ChartPanel 리렌더 불필요 if (prev.chartData !== next.chartData) return false if (prev.chartLoading !== next.chartLoading) return false if (prev.selectedStock !== next.selectedStock) return false if (prev.chartPeriod !== next.chartPeriod) return false if (prev.stockName !== next.stockName) return false if (prev.onPeriodChange !== next.onPeriodChange) return false - // livePrice 변경은 무시 (TradingChart가 ref로 직접 처리) + // 실시간 현재가/거래량 — TradingChart 가 마지막 캔들만 update (재생성 없음) + if (prev.livePrice !== next.livePrice) return false + if (prev.liveVolume !== next.liveVolume) return false + if (prev.emptyReason !== next.emptyReason) return false return true }) @@ -129,6 +146,22 @@ interface AiAnalysisPanelProps { export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelProps) => { const aiAnalysisText = aiAnalysisData?.analysis || null + // 결론 action 정규화 (구조화 필드 우선, 없으면 recommendation) + const rawAction = String( + aiAnalysisData?.data?.action ?? aiAnalysisData?.data?.conclusion?.action ?? aiAnalysisData?.data?.recommendation ?? '' + ).toUpperCase() + const action: 'BUY' | 'SELL' | 'HOLD' | null = + rawAction.includes('BUY') || rawAction.includes('매수') ? 'BUY' + : rawAction.includes('SELL') || rawAction.includes('매도') ? 'SELL' + : rawAction.includes('HOLD') || rawAction.includes('관망') ? 'HOLD' + : null + // 매수 결론일 때만 매수 제안 금액/목표가/손절가를 노출 (HOLD/SELL 이면 모순된 매수 제안 숨김) + const showBuyPlan = action === 'BUY' || action === null + const d = aiAnalysisData?.data + const hasTarget = d?.target_price != null && Number(d.target_price) > 0 + const headline: string | null = typeof d?.headline === 'string' && d.headline ? d.headline : null + const sizingTitleFallback = action === 'HOLD' ? '관망 (매수 제안 없음)' : action === 'SELL' ? '매도 의견 (매수 제안 없음)' : '매수 추천' + const sizingTitle = headline ?? sizingTitleFallback return (
{/* Header */} @@ -147,8 +180,8 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr {aiAnalysisData?.data && (
- 매수 추천 - {aiAnalysisData.data.suggested_quantity > 0 ? ( + {sizingTitle} + {showBuyPlan && aiAnalysisData.data.suggested_quantity > 0 ? ( <> {Number(aiAnalysisData.data.suggested_amount).toLocaleString('ko-KR')}원 @@ -166,25 +199,25 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr )}
- {aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && ( + {showBuyPlan && aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && (
{aiAnalysisData.data.sizing_note}
)} {/* 목표가 · 손절가 (CLAUDE.md §5.2: 손절은 ATR/퍼센트 중 타이트한 쪽, 목표는 +2R) */} - {aiAnalysisData.data.target_price > 0 && ( + {showBuyPlan && hasTarget && (
목표 {Number(aiAnalysisData.data.target_price).toLocaleString('ko-KR')}원 - (+{aiAnalysisData.data.target_pct}%) + {aiAnalysisData.data.target_pct != null && <>(+{aiAnalysisData.data.target_pct}%)} 손절 - {Number(aiAnalysisData.data.stop_loss_price).toLocaleString('ko-KR')}원 - ({aiAnalysisData.data.stop_loss_pct}%) + {aiAnalysisData.data.stop_loss_price != null ? `${Number(aiAnalysisData.data.stop_loss_price).toLocaleString('ko-KR')}원` : '-'} + {aiAnalysisData.data.stop_loss_pct != null && ({aiAnalysisData.data.stop_loss_pct}%)} - R:R 1:{aiAnalysisData.data.risk_reward} + {aiAnalysisData.data.risk_reward != null && <>R:R 1:{aiAnalysisData.data.risk_reward}}
)} diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..8de46a9 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,75 @@ +/** + * ConfirmDialog.tsx — 앱 내 확인 모달 (주문 전 확인용) + * + * window.confirm 대신 사용. 처리 중(busy)에는 버튼 비활성화로 중복 클릭을 막고, + * 배경 클릭/ESC 로 취소할 수 있다(처리 중에는 닫히지 않음). + */ +import React, { useEffect } from 'react' +import { Loader2 } from 'lucide-react' + +interface ConfirmDialogProps { + open: boolean + title: string + /** 본문 (줄 단위 배열 또는 노드) */ + lines: React.ReactNode[] + confirmLabel: string + /** 'sell' = 파랑(매도), 'buy' = 빨강(매수, 한국 관례) */ + tone?: 'sell' | 'buy' + busy?: boolean + onConfirm: () => void + onCancel: () => void +} + +const ConfirmDialog = ({ open, title, lines, confirmLabel, tone = 'sell', busy, onConfirm, onCancel }: ConfirmDialogProps) => { + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !busy) onCancel() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, busy, onCancel]) + + if (!open) return null + const confirmCls = tone === 'buy' + ? 'bg-[#b91c1c] hover:bg-[#dc2626]' + : 'bg-[#1d4ed8] hover:bg-[#2563eb]' + + return ( +
{ e.stopPropagation(); if (!busy) onCancel() }} + role="dialog" + aria-modal="true" + > +
e.stopPropagation()} + > +

{title}

+
+ {lines.map((l, i) =>

{l}

)} +
+
+ + +
+
+
+ ) +} + +export default ConfirmDialog diff --git a/frontend/src/components/JournalPanel.tsx b/frontend/src/components/JournalPanel.tsx index 61206c7..f6dc160 100644 --- a/frontend/src/components/JournalPanel.tsx +++ b/frontend/src/components/JournalPanel.tsx @@ -13,9 +13,20 @@ * - journals: react-query 'journals' 쿼리 결과 * - tradeHistory: react-query 'trade-history' 쿼리 결과 */ -import React, { useState } from 'react' +import React, { useMemo, useState } from 'react' import { BookOpen, ArrowUpDown } from 'lucide-react' import { Journal, TradeHistory } from '../types/trading' +import { parseKst } from '../utils/formatUtils' + +const kstDateKey = (d: Date | null): string => + d ? d.toLocaleDateString('sv-SE', { timeZone: 'Asia/Seoul' }) : '' + +const kstDateLabel = (d: Date | null, todayKey: string): string => { + if (!d) return '날짜 미상' + const key = kstDateKey(d) + const md = d.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', weekday: 'short', timeZone: 'Asia/Seoul' }) + return key === todayKey ? `${md} (오늘)` : md +} /** JournalPanel Props 정의 */ interface JournalPanelProps { @@ -38,6 +49,25 @@ interface JournalPanelProps { const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, resetDisabled }: JournalPanelProps) => { const [tab, setTab] = useState<'trades' | 'journal'>('trades') + // 최신순 정렬 (created_at/executed_at 기준, KST 보정). 시각 파싱 불가 건은 id 로 보조 정렬 + const sortedTrades = useMemo(() => { + const todayKey = kstDateKey(new Date()) + return (tradeHistory ?? []) + .map((t: TradeHistory) => { + const dt = parseKst(t.created_at || t.executed_at || '') + return { t, dt, key: kstDateKey(dt) } + }) + .sort((a, b) => { + const d = (b.dt?.getTime() ?? 0) - (a.dt?.getTime() ?? 0) + return d !== 0 ? d : Number(b.t.id || 0) - Number(a.t.id || 0) + }) + .map((x, i, arr) => ({ + ...x, + // 날짜가 바뀌는 첫 행에 구분선 라벨 + header: i === 0 || arr[i - 1].key !== x.key ? kstDateLabel(x.dt, todayKey) : null, + })) + }, [tradeHistory]) + return (
{/* Header with tabs */} @@ -83,8 +113,8 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
{tab === 'trades' ? ( /* ── 매매내역 탭 ── */ - tradeHistory && tradeHistory.length > 0 ? ( - tradeHistory.map((trade: TradeHistory, idx: number) => { + sortedTrades.length > 0 ? ( + sortedTrades.map(({ t: trade, dt: createdAt, header }, idx: number) => { // 거래 유형 판별 (매수/매도) const isBuy = trade.order_type === 'BUY' const isSell = trade.order_type === 'SELL' @@ -98,17 +128,25 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset const statusOk = st === 'COMPLETED' || st === 'EXECUTED' || st === 'FILLED' const isFailed = st === 'FAILED' - // DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정 - const rawDt = trade.created_at || trade.executed_at || '' - const createdAt = new Date(rawDt.includes('+') || rawDt.includes('Z') ? rawDt : rawDt + '+09:00') - const timeStr = createdAt.toLocaleTimeString('ko-KR', { - hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: false, timeZone: 'Asia/Seoul', - }) + // DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정 (parseKst) + const timeStr = createdAt + ? createdAt.toLocaleTimeString('ko-KR', { + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, timeZone: 'Asia/Seoul', + }) + : '--:--:--' + const dateShort = createdAt + ? createdAt.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' }) + : '' return ( + + {header && ( +
+ {header} +
+ )}
실패 )}
- {timeStr} + {dateShort} {timeStr}
- {/* 시장가 주문은 order_price가 null → 체결가(executed_price) 우선 표시 */} - {Number(trade.executed_quantity || trade.order_quantity || 0)}주 × {Math.round(Number(trade.executed_price || trade.order_price || 0)).toLocaleString()}원 + {/* 체결가(executed_price) 우선. null 이면 아직 체결 확인 전 — 주문가는 회색으로 구분 */} + {Number(trade.executed_price) > 0 ? ( + <>{Number(trade.executed_quantity || trade.order_quantity || 0)}주 × {Math.round(Number(trade.executed_price)).toLocaleString()}원 + ) : ( + <> + {Number(trade.order_quantity || trade.executed_quantity || 0)}주 + {Number(trade.order_price) > 0 && ( + × 주문가 {Math.round(Number(trade.order_price)).toLocaleString()}원 + )} + {!isFailed && 체결 확인 중} + + )}
{isSell && statusOk && pnl !== 0 && (
@@ -152,6 +200,7 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
{trade.exit_reason}
)}
+ ) }) ) : ( diff --git a/frontend/src/components/PortfolioPanel.tsx b/frontend/src/components/PortfolioPanel.tsx index 02bcd32..fba0eea 100644 --- a/frontend/src/components/PortfolioPanel.tsx +++ b/frontend/src/components/PortfolioPanel.tsx @@ -13,27 +13,20 @@ * 한국/미국 시장 구분: * - 종목 코드가 6자리 숫자면 KR, 그 외는 US */ -import React, { useMemo } from 'react' +import React, { useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import api from '../lib/api' -import { Activity, TrendingUp, UserCircle, Globe } from 'lucide-react' +import { Activity, TrendingUp, UserCircle, Globe, AlertTriangle } from 'lucide-react' import { Position } from '../types/trading' import { isUsStock } from '../utils/stockUtils' import { groupHoldings, GroupedHolding } from '../utils/portfolioGrouping' +import { formatPriceByMarket } from '../utils/formatUtils' import AccountSetupNotice from './AccountSetupNotice' +import ConfirmDialog from './ConfirmDialog' import DayTradingStatusBar from './DayTradingStatusBar' -/** - * 가격 포맷 — 시장에 따라 원화 또는 달러 표시 - * @param price - 가격 - * @param isUs - 미국 주식 여부 - */ -const formatPrice = (price: number, isUs: boolean): string => { - if (isUs) { - return `$${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` - } - return `${price.toLocaleString()}원` -} +/** 가격 포맷 — 원화는 정수 반올림, USD 는 소수 2자리 */ +const formatPrice = formatPriceByMarket /** 데이트레이딩 엔진 포지션 (GET /daytrading/positions) */ interface DayTradePosition { @@ -62,6 +55,8 @@ interface PortfolioItemProps { onSell: (stockCode: string, stockName: string, quantity: number) => Promise /** true면 청산 버튼 비활성화 (예: 사용자 모드 확정 전) */ sellDisabled?: boolean + /** 속한 그룹 (스윙 카드는 swing_avg_price 우선) */ + groupKey?: 'swing' | 'manual' | 'overseas' | 'unmanaged' } /** @@ -83,15 +78,42 @@ const PortfolioItem = React.memo(({ onRefetchAiAnalysis, onSell, sellDisabled, + groupKey, }: PortfolioItemProps) => { // 현재가: 실시간 가격 우선, 없으면 포트폴리오 API 가격 사용 const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0) - const avgPrice = Number(stock.average_price || 0) + // 스윙 카드는 전략별 평단(swing_avg_price)이 있으면 우선 (합산 행 평단과 다를 수 있음) + const isSwingCard = groupKey === 'swing' + const swingAvg = Number(stock.swing_avg_price || 0) + const useSwingBasis = isSwingCard && swingAvg > 0 + const avgPrice = useSwingBasis ? swingAvg : Number(stock.average_price || 0) const quantity = Number(stock.quantity || 0) - // 수익률: 평균단가 기반 실시간 계산 (평균단가 없으면 API 값 폴백) - const profitLossRate = avgPrice > 0 - ? ((currentPrice - avgPrice) / avgPrice) * 100 + // 수익률: 평균단가 기반 실시간 계산 (현재가/평단 없으면 API 값 폴백 — 스윙 카드는 swing_* 우선) + const fallbackRate = isSwingCard && stock.swing_profit_loss_rate != null + ? Number(stock.swing_profit_loss_rate) : Number(stock.profit_loss_rate || 0) + const profitLossRate = avgPrice > 0 && currentPrice > 0 + ? ((currentPrice - avgPrice) / avgPrice) * 100 + : fallbackRate + // 평가손익(원): 실시간 계산, 불가하면 스윙 카드는 swing_profit_loss 폴백 + const profitLoss: number | null = avgPrice > 0 && currentPrice > 0 + ? (currentPrice - avgPrice) * quantity + : isSwingCard && stock.swing_profit_loss != null + ? Number(stock.swing_profit_loss) + : null + // 합산 평단(스윙+DT)을 분리한 경우 — 근거 툴팁 + const avgMixed = isSwingCard && !!stock.avg_price_mixed + const basisLabel: Record = { + active_trade: '스윙 진입 기록 평단', + conservative_max: '보수적(높은 쪽) 평단', + portfolio_avg: '계좌 평단', + derived_from_mixed: '합산 평단에서 DT 분 역산', + none: '평단 근거 없음', + } + const basis = String(stock.swing_avg_price_basis || '') + const mixedTitle = `스윙·데이트레이딩 합산 평단을 분리해 표시합니다 (근거: ${basisLabel[basis] ?? (basis || '알 수 없음')})` + + (Number(stock.dt_quantity) > 0 ? ` · DT ${Number(stock.dt_quantity)}주 @ ${Math.round(Number(stock.dt_avg_price || 0)).toLocaleString()}` : '') + + (Number(stock.average_price) > 0 ? ` · 계좌 합산 평단 ${Math.round(Number(stock.average_price)).toLocaleString()}` : '') const isUs = isUsStock(stock.stock_code) const isSelected = selectedStock === stock.stock_code const isUp = profitLossRate >= 0 @@ -99,9 +121,21 @@ const PortfolioItem = React.memo(({ const splitLocked = Number((stock as GroupedHolding).isSplit) > 0 || Number((stock as GroupedHolding).dtIncluded) > 0 const sellBlocked = !!sellDisabled || splitLocked - const handleSellClick = async (e: React.MouseEvent) => { + const [confirmOpen, setConfirmOpen] = useState(false) + const [selling, setSelling] = useState(false) + const inflightRef = useRef(false) + + const handleSellClick = (e: React.MouseEvent) => { e.stopPropagation() - if (sellBlocked) return + if (sellBlocked || selling || inflightRef.current) return + setConfirmOpen(true) + } + + const handleConfirmSell = async () => { + // 중복 클릭 방지: ref 로 동기 차단 (state 갱신 전 연타 대비) + if (inflightRef.current) return + inflightRef.current = true + setSelling(true) try { await onSell(stock.stock_code, stock.stock_name, quantity) } catch (error: unknown) { @@ -109,6 +143,10 @@ const PortfolioItem = React.memo(({ // 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략 if (err?.isAccountSetupRequired) return // 실패 알림은 useTradeActions의 toast.error로 표시됨 (alert는 화면 블로킹) + } finally { + inflightRef.current = false + setSelling(false) + setConfirmOpen(false) } } @@ -141,6 +179,14 @@ const PortfolioItem = React.memo(({ {stock.strategy_type === 'external' ? '외부' : '수동'} )} + {Number((stock as GroupedHolding).unmanaged) > 0 && ( + + 미관리 + + )} {Number((stock as GroupedHolding).dtIncluded) > 0 && ( - {formatPrice(avgPrice, isUs)} + + {formatPrice(avgPrice, isUs)} + {avgMixed && ( + + 혼합평단 분리 + + )} + {formatPrice(currentPrice, isUs)} @@ -174,13 +227,29 @@ const PortfolioItem = React.memo(({
+ setConfirmOpen(false)} + lines={[ + <>{stock.stock_name} ({stock.stock_code}), + <>수량 {quantity.toLocaleString()}{isUs ? '주(shares)' : '주'} 전량을 시장가로 매도합니다., + <>현재가 {formatPrice(currentPrice, isUs)} · 평가손익 {profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%{profitLoss != null && !isUs ? ` (${profitLoss > 0 ? '+' : ''}${Math.round(profitLoss).toLocaleString()}원)` : ''}, + ...(Number((stock as GroupedHolding).unmanaged) > 0 ? [엔진 미관리 잔고입니다.] : []), + 체결 후에는 되돌릴 수 없습니다., + ]} + />
) }) @@ -207,6 +276,8 @@ interface PortfolioPanelProps { accountSetupRequired?: boolean /** 계좌 미등록/미검증 안내 문구 (accountSetupRequired=true일 때만 사용) */ accountSetupMessage?: string + /** USD/KRW 환율 (서버 summary.exchange_rate). 0/없음이면 '환율 미수신' */ + exchangeRate?: number } /** @@ -225,11 +296,12 @@ const PortfolioPanel = React.memo(({ sellDisabled, accountSetupRequired, accountSetupMessage, + exchangeRate, }: PortfolioPanelProps) => { // 데이트레이딩 포지션은 매매 엔진(인메모리)이 진실 — Portfolio 테이블은 종목당 1행이라 // 같은 종목을 스윙+데이트레이딩으로 동시 보유하면 구분이 사라진다. 엔진에서 직접 조회해 // 데이트레이딩 그룹으로 표시하고, 스윙 행에서는 그 수량을 뺀다. (2026-09-23) - const { data: dtPositions, isError: dtError } = useQuery({ + const { data: dtPositions, isError: dtError, isLoading: dtLoading } = useQuery({ queryKey: ['daytrading-positions'], queryFn: async () => (await api.get('/daytrading/positions')).data?.positions ?? [], refetchInterval: 30000, @@ -237,7 +309,7 @@ const PortfolioPanel = React.memo(({ }) const dtRows: Position[] | undefined = useMemo(() => { - if (dtError) return undefined // 엔진 수량 불명 -> 합산 행에 "DT 포함" 배지 + if (dtError || dtLoading || dtPositions === undefined) return undefined // 로딩/실패: 엔진 수량 불명 -> 합산 행에 "DT 포함" 배지 return (dtPositions ?? []) .filter(p => p.quantity > 0) .map(p => ({ @@ -250,10 +322,11 @@ const PortfolioPanel = React.memo(({ strategy_type: 'daytrading', market: 'KRX', })) - }, [dtPositions, dtError]) + }, [dtPositions, dtError, dtLoading]) const groups = useMemo(() => groupHoldings(portfolio, dtRows), [portfolio, dtRows]) - const total = groups.swing.length + groups.manual.length + groups.overseas.length + // 미관리(엔진 외) DT 잔고 포함. 엔진이 관리 중인 DT 포지션은 하단 상태바에서 확인 + const total = groups.swing.length + groups.manual.length + groups.overseas.length + groups.unmanaged.length /** 그룹 소계 (실시간 가격 우선). 통화가 다르므로 그룹 내 같은 통화끼리만 합산 */ const subtotal = (stocks: Position[]) => { @@ -276,6 +349,8 @@ const PortfolioPanel = React.memo(({ color: string, note?: string, usd?: boolean, + groupKey: 'swing' | 'manual' | 'overseas' | 'unmanaged' = 'swing', + warn?: boolean, ) => { if (stocks.length === 0) return null const sub = subtotal(stocks) @@ -283,16 +358,20 @@ const PortfolioPanel = React.memo(({ const fmt = (n: number) => usd ? `$${Math.round(n).toLocaleString('en-US')}` : `${Math.round(n).toLocaleString()}원` + const rateOk = Number(exchangeRate) > 0 + const krwNote = usd + ? (rateOk ? `≈ ${Math.round(sub.value * Number(exchangeRate)).toLocaleString()}원` : '환율 미수신') + : null return (
-
+
{icon} {label} {stocks.length}
- {fmt(sub.value)} + {fmt(sub.value)}{krwNote && {krwNote}} {up ? '+' : ''}{fmt(sub.pnl)} ({up ? '+' : ''}{sub.rate.toFixed(2)}%) @@ -309,6 +388,7 @@ const PortfolioPanel = React.memo(({ onRefetchAiAnalysis={onRefetchAiAnalysis} onSell={onSell} sellDisabled={sellDisabled} + groupKey={groupKey} /> ))}
@@ -327,6 +407,9 @@ const PortfolioPanel = React.memo(({ {groups.swing.length > 0 && ( 스윙 {groups.swing.length} )} + {groups.unmanaged.length > 0 && ( + 미관리 {groups.unmanaged.length} + )} {groups.manual.length > 0 && ( 수동 {groups.manual.length} )} @@ -345,10 +428,12 @@ const PortfolioPanel = React.memo(({
) : total > 0 ? ( <> - {/* 데이트레이딩 포지션은 보유종목 목록에서 제외(사용자 요청 2026-10-06) — 하단 DayTradingStatusBar 에서 확인 */} - {renderGroup(groups.swing, '스윙', , 'text-[#a78bfa]')} - {renderGroup(groups.manual, '수동 / 외부', , 'text-[#eab308]', '자동 청산 대상 아님 - 직접 청산 필요')} - {renderGroup(groups.overseas, '해외', , 'text-[#22c55e]', undefined, true)} + {/* 엔진이 관리 중인 데이트레이딩 포지션은 목록에서 제외(사용자 요청 2026-10-06) — 하단 DayTradingStatusBar 에서 확인. + 단 엔진이 모르는 DT 잔고는 반드시 경고 그룹으로 표시한다. */} + {renderGroup(groups.unmanaged, '미관리 (엔진 외) 보유', , 'text-[#fb923c]', '데이트레이딩 엔진이 관리하지 않는 잔고 - 자동 청산되지 않으니 직접 확인/청산 필요', false, 'unmanaged', true)} + {renderGroup(groups.swing, '스윙', , 'text-[#a78bfa]', undefined, false, 'swing')} + {renderGroup(groups.manual, '수동 / 외부', , 'text-[#eab308]', '자동 청산 대상 아님 - 직접 청산 필요', false, 'manual')} + {renderGroup(groups.overseas, '해외', , 'text-[#22c55e]', undefined, true, 'overseas')} ) : (
diff --git a/frontend/src/components/PortfolioSummary.tsx b/frontend/src/components/PortfolioSummary.tsx index 8f13f3f..b566eaf 100644 --- a/frontend/src/components/PortfolioSummary.tsx +++ b/frontend/src/components/PortfolioSummary.tsx @@ -78,13 +78,22 @@ export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetup // 핵심 지표 추출 const total_assets = n(summary.total_assets); // 총 자산 (예수금 + 주식 평가) - const available_cash = n(summary.available_cash); // 예수금 + // 예수금: 총자산 기준 현금(총자산 − 주식평가)이 있으면 그것을 표시 → 화면에서 총자산 = 주식 + 예수금 이 맞는다 + // (KIS 예수금(dnca)은 결제 전 금액이라 총자산 구성과 어긋날 수 있음) + const hasCashForTotal = summary.cash_for_total_assets !== undefined && summary.cash_for_total_assets !== null; + const available_cash = hasCashForTotal ? n(summary.cash_for_total_assets) : n(summary.available_cash); + const hasOrderableCash = summary.orderable_cash !== undefined && summary.orderable_cash !== null; + const orderable_cash = n(summary.orderable_cash); + const fxUnavailable = summary.exchange_rate_unavailable === true; const total_value = n(summary.total_value); // 주식 평가금액 const total_investment = n(summary.total_investment); // 총 매입금액 const total_profit_loss = n(summary.total_profit_loss); // 평가손익 const total_profit_loss_rate = n(summary.total_profit_loss_rate); // 수익률 const today_profit_loss = n(summary.today_profit_loss); // 금일 손익 const today_profit_loss_rate = n(summary.today_profit_loss_rate); // 금일 수익률 + // 해외 보유(USD) 원화 환산: 환율이 0/없으면 임의 환산하지 않고 '환율 미수신' 표기 + const overseasUsd = n(summary.overseas_value_usd); + const fxRate = n(summary.exchange_rate); // 누적 실현손익 — 아직 조회되지 않은 상태(undefined/null)와 실제 0원을 구분한다. // (2026-09-28: legacy 모드는 별도 쿼리로 병합되는 필드라 초기 렌더 시 undefined일 수 있음) const hasCumulativeRealizedPl = summary.cumulative_realized_pl !== undefined && summary.cumulative_realized_pl !== null; @@ -121,13 +130,21 @@ export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetup {fmt(total_assets)}원
- + 예수금 {fmt(available_cash)}원 ({cashRatio.toFixed(0)}%)
+ {hasOrderableCash && ( +
+ 주문가능 {fmt(orderable_cash)}원 +
+ )}
{/* 평가손익 + 수익률 (합침) */} @@ -147,6 +164,14 @@ export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetup
주식 {fmt(total_value)}원 · 매입 {fmt(total_investment)}원
+ {overseasUsd > 0 && ( +
+ 해외 ${Math.round(overseasUsd).toLocaleString('en-US')}{' '} + {fxRate > 0 && !fxUnavailable + ? `(≈ ${fmt(overseasUsd * fxRate)}원)` + : 환율 미수신} +
+ )}
{/* 금일 손익 */} diff --git a/frontend/src/components/RecommendationPanel.tsx b/frontend/src/components/RecommendationPanel.tsx index b49c302..0839875 100644 --- a/frontend/src/components/RecommendationPanel.tsx +++ b/frontend/src/components/RecommendationPanel.tsx @@ -15,10 +15,69 @@ * 최적화: * - RecommendationItem, RecommendationPanel 모두 React.memo 적용 */ -import React, { useState } from 'react' +import React, { useMemo, useState } from 'react' import { RefreshCw } from 'lucide-react' import { RecommendationsData, RecommendedStock, Recommendation } from '../types/trading' -import { formatTime } from '../utils/formatUtils' +import { parseKst } from '../utils/formatUtils' +import ConfirmDialog from './ConfirmDialog' + +/** 대체 규칙 이름 — 규칙 미충족 종목을 점수순으로 채우는 출처 */ +const FALLBACK_RULE_NAME = '고점수 분석 종목' +/** 백엔드가 구조화 필드를 주지 않을 때 문자열 사유에서 매수 차단을 추론하는 패턴 */ +const BLOCK_PATTERN = /차단|급등\s*과열|거래량\s*부족/ + +/** 배치 시각 표시: KST ISO 면 날짜+시각, 날짜만 오면 날짜만 (시각 생략) */ +const formatBatchTime = (raw?: string): string | null => { + if (!raw) return null + if (/^\d{4}-\d{2}-\d{2}$/.test(raw.trim())) return raw.trim().slice(5).replace('-', '/') + const d = parseKst(raw) + if (!d) return null + const date = d.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' }) + const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' }) + return `${date} ${time}` +} + +const asStrings = (v: unknown): string[] => { + if (typeof v === 'string') return v ? [v] : [] + if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string' && x.length > 0) + return [] +} + +/** 종목 + (있다면) 추천 항목에서 출처/차단/ML 정보 추출 — 백엔드 확정 필드 우선, 없을 때만 추론 */ +const deriveInfo = (stock: any, rec: any, ruleNames: string[]) => { + const src = [stock, rec].filter(Boolean) + // 출처: match_source 확정 필드 우선 + const ms = src.map(o => o.match_source).find(v => typeof v === 'string' && v) as string | undefined + let fallback: boolean + if (ms) { + fallback = ms === 'fallback_high_score' + } else { + // 폴백: 규칙 이름으로 추론 + fallback = src.some(o => o.is_fallback_rule === true) || + [...src.flatMap(o => asStrings(o.source_rule)), ...src.flatMap(o => asStrings(o.rule_name)), ...ruleNames] + .some(n => n.includes(FALLBACK_RULE_NAME)) + } + // 차단: buy_allowed === false 가 정본, block_reasons 가 사유 + const hasBuyAllowed = src.some(o => typeof o.buy_allowed === 'boolean') + const buyAllowedFalse = src.some(o => o.buy_allowed === false) + const reasons = src.flatMap(o => asStrings(o.block_reasons)) + const mlBlocks = src.some(o => o.ml_blocks_buy === true) + if (mlBlocks) reasons.push('ML SELL') + let blocked = buyAllowedFalse + if (!hasBuyAllowed && reasons.length === 0) { + // 폴백: 확정 필드가 전혀 없을 때만 문자열 사유에서 추론 + const inferred = [ + ...src.flatMap(o => asStrings(o.signals)), + ...src.flatMap(o => asStrings(o.reasons)), + ...src.flatMap(o => asStrings(o.warnings)), + ].filter(t => BLOCK_PATTERN.test(t)) + reasons.push(...inferred) + blocked = reasons.length > 0 + } + const mlRaw = src.map(o => o.ml_signal).find(v => typeof v === 'string' && v) as string | undefined + return { fallback, blocked, reasons: Array.from(new Set(reasons)), ml: mlRaw ? mlRaw.toUpperCase() : undefined } +} + /** RecommendationItem Props 정의 */ interface RecommendationItemProps { @@ -34,6 +93,13 @@ interface RecommendationItemProps { onBuy: (stockCode: string, stockName: string) => Promise /** true면 매수 버튼 비활성화 (예: 사용자 모드 확정 전) */ buyDisabled?: boolean + /** 대체 규칙('고점수 분석 종목') 출처 — '신호'가 아니라 '관심(규칙 미충족)' 표시 */ + isFallback?: boolean + /** 매수 차단 여부 및 사유(툴팁) */ + blocked?: boolean + blockReasons?: string + /** ML 신호 (BUY/SELL/HOLD) */ + mlSignal?: string } const RecommendationItem = React.memo(({ @@ -48,16 +114,25 @@ const RecommendationItem = React.memo(({ livePrices, onBuy, buyDisabled, + isFallback, + blocked, + blockReasons, + mlSignal, }: RecommendationItemProps) => { const changeRate = stock.change_rate || 0 const isSelected = selectedStock === stock.stock_code const isUp = changeRate >= 0 const [buying, setBuying] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) - const handleBuyClick = async (e: React.MouseEvent) => { + const handleBuyClick = (e: React.MouseEvent) => { e.stopPropagation() - if (buying || buyDisabled) return - if (!window.confirm(`${stock.stock_name} 시장가 매수할까요?\n(수량: 총자산 × 종목당 최대비중으로 자동 산출)`)) return + if (buying || buyDisabled || blocked) return + setConfirmOpen(true) + } + + const handleConfirmBuy = async () => { + if (buying) return setBuying(true) try { await onBuy(stock.stock_code, stock.stock_name) @@ -68,6 +143,7 @@ const RecommendationItem = React.memo(({ // 실패 알림은 useTradeActions의 toast.error로 표시됨 (alert는 화면 블로킹) } finally { setBuying(false) + setConfirmOpen(false) } } @@ -98,19 +174,27 @@ const RecommendationItem = React.memo(({ ) : isHeld ? ( 보유 + ) : isRecommended && isFallback ? ( + 관심(규칙 미충족) ) : isRecommended ? ( 신호 ) : null} + {blocked && !isHeld && !isPending && ( + 차단 + )} {changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
{/* Row 2: code + price + RSI + score */} -
+
{stock.stock_code} {Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원 + {stock.price_source === 'batch' && livePrices?.[stock.stock_code] == null && ( + 배치 + )} {stock.rsi != null && ( 70 ? 'text-[#e84444]' : 'text-[#4a6580]'}`}> @@ -123,13 +207,23 @@ const RecommendationItem = React.memo(({ {(stock.score ?? 0) > 0 && ( {stock.score}pt )} + {mlSignal && ( + + ML {mlSignal} + + )}
{/* Row 3: signals */} {stock.signals && stock.signals.length > 0 && (
- - {stock.signals.slice(0, 2).join(' · ')} + {/* 잘림 방지: 한 줄 truncate 대신 줄바꿈 허용 (ML SELL 등 뒤쪽 신호가 가려지지 않게) */} + + {stock.signals.join(' · ')}
)} @@ -140,11 +234,25 @@ const RecommendationItem = React.memo(({
+ setConfirmOpen(false)} + lines={[ + <>{stock.stock_name} ({stock.stock_code}), + <>시장가 매수 (수량: 총자산 x 종목당 최대비중으로 자동 산출), + ]} + />
)}
@@ -192,6 +300,19 @@ const RecommendationPanel = React.memo(({ onBuy, buyDisabled, }: RecommendationPanelProps) => { + // 종목코드 -> 추천 항목 / 출처 규칙 이름 (구조화 필드가 없을 때 추론용) + const { recMap, ruleMap } = useMemo(() => { + const rm = new Map() + for (const r of recommendations?.recommendations || []) rm.set(r.stock_code, r) + const um = new Map() + for (const g of recommendations?.recommendations_by_rule || []) { + for (const st of g.stocks || []) { + um.set(st.stock_code, [...(um.get(st.stock_code) || []), g.rule_name]) + } + } + return { recMap: rm, ruleMap: um } + }, [recommendations]) + return (
{/* Header */} @@ -200,9 +321,9 @@ const RecommendationPanel = React.memo(({ 분석 종목 스캐너
- {recommendations?.batch_time && ( + {(recommendations?.batch_time || recommendations?.batch_date) && ( - {formatTime(recommendations.batch_time)} + {formatBatchTime(recommendations.batch_time) ?? formatBatchTime(recommendations.batch_date)} )} @@ -237,6 +358,7 @@ const RecommendationPanel = React.memo(({ const isRecommended = (recommendations?.recommendations || []).some((r: Recommendation) => r.stock_code === stock.stock_code) const isHeld = heldStockCodes.has(stock.stock_code) const isPending = pendingBuyStockCodes.has(stock.stock_code) + const info = deriveInfo(stock, recMap.get(stock.stock_code), ruleMap.get(stock.stock_code) || []) return ( ) }) diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 80d0c6a..d456aad 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -4,11 +4,14 @@ * 역할: * - 4단 차트 구성: 캔들+볼린저+이평선 | 거래량 | RSI(14) | MACD * - 모든 차트의 시간축 동기화 (메인 차트 스크롤 시 하위 차트 연동) - * - 실시간 현재가 반영 (500ms polling, ref 기반 — 리렌더 없음) + * - 실시간 현재가 반영 (livePrice prop 변경 시 마지막 캔들만 update — 차트 재생성 없음) + * · 일봉: 마지막 캔들이 오늘이면 update, 과거면 국내 장중에만 오늘 캔들 추가 + * · 주/월봉: 마지막 캔들이 이번 주/월일 때만 update (아니면 무시) + * · 장중 고가/저가는 ref 로 누적, 거래량은 당일 누적(ACML_VOL)이 있으면 일봉에만 반영 * - 반응형 크기 조절 (ResizeObserver + window resize) * * 최적화 전략: - * - React.memo + 커스텀 비교 함수: livePrice 변경 무시, data 첫/끝 캔들만 비교 + * - React.memo + 커스텀 비교 함수: data 첫/끝 캔들 + livePrice/liveVolume 비교 * - 차트 인스턴스는 마운트 시 1회만 생성 (chartsCreatedRef) * - 데이터 변경 시 시리즈 데이터만 교체 (차트 재생성 없음 → 깜빡임 방지) * - 기술적 지표 계산은 useMemo로 캐싱 (O(n) 슬라이딩 윈도우) @@ -24,13 +27,45 @@ import { calculateMACD } from '../utils/technicalIndicators'; import { CandleData, ProcessedChartData } from '../types/trading'; +import { isKrMarketOpen } from '../utils/marketHours'; +import { isUsStock } from '../utils/stockUtils'; /** TradingChart Props 정의 */ interface TradingChartProps { /** 캔들 데이터 배열 (OHLCV) */ data: CandleData[]; - /** 실시간 현재가 (ref 기반 처리, 리렌더 트리거 안 함) */ + /** 실시간 현재가 — 변경 시 마지막 캔들만 update (차트 재생성 없음) */ livePrice?: number; + /** 당일 누적 거래량 (일봉 오늘 캔들에만 반영) */ + liveVolume?: number; + /** 차트 기간 (D/W/M) — 실시간 캔들 반영 규칙 결정 */ + period?: 'D' | 'W' | 'M'; + /** 종목 코드 — 국내/해외 판정(오늘 날짜 기준 시간대, 장중 캔들 추가 여부) */ + stockCode?: string; +} + +/** 지정 시간대 기준 오늘 날짜 'YYYY-MM-DD' */ +const todayInTz = (timeZone: string): string => { + // en-CA 로캘은 YYYY-MM-DD 형식 + return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); +}; + +/** 'YYYY-MM-DD' 가 속한 주의 월요일 'YYYY-MM-DD' (UTC 계산 — 시간대 영향 없음) */ +const weekStartOf = (ymd: string): string => { + const [y, m, d] = ymd.split('-').map(Number); + if (!y || !m || !d) return ''; + const t = Date.UTC(y, m - 1, d); + const dow = new Date(t).getUTCDay(); // 0=일 + const monday = new Date(t - ((dow + 6) % 7) * 86400000); + return monday.toISOString().slice(0, 10); +}; + +/** 실시간으로 갱신 중인 캔들 상태 (setData 시 초기화) */ +interface LiveCandleState { + time: string; + open: number; + high: number; + low: number; } // 한국 시장 색상 규칙 (상승=빨강, 하락=파랑) @@ -50,10 +85,16 @@ const COLORS = { border: '#374151', } as const; -export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: TradingChartProps) => { - // livePrice를 ref로 유지 — 리렌더 없이 useEffect에서 최신 값 사용 +export const TradingChart = React.memo(({ data, livePrice: livePriceProp, liveVolume, period = 'D', stockCode = '' }: TradingChartProps) => { + // 최신 props 를 ref 로 유지 — 차트 생성/데이터 교체 콜백에서 참조 const livePriceRef = useRef(livePriceProp); livePriceRef.current = livePriceProp; + const liveVolumeRef = useRef(liveVolume); + liveVolumeRef.current = liveVolume; + const periodRef = useRef(period); + periodRef.current = period; + const stockCodeRef = useRef(stockCode); + stockCodeRef.current = stockCode; const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(350); // containerHeight를 ref로도 유지 — ResizeObserver 클로저가 최신 값 참조 (Observer 마운트 1회만 생성) @@ -264,6 +305,11 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad volumeSeriesRef.current = null; }; + // 실시간 캔들 상태 refs (applySeriesData 에서 초기화) + const liveCandleRef = useRef(null); + const lastAppliedPriceRef = useRef(null); + const lastAppliedVolumeRef = useRef(null); + // 보조 시리즈 refs (데이터 교체용) const mainLineSeriesRef = useRef[]>([]); const rsiLineSeriesRef = useRef[]>([]); @@ -292,8 +338,11 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad try { chart?.timeScale().setVisibleLogicalRange(logicalRange); } catch (_) { /* not laid out yet */ } }; - // 메인 차트 데이터 교체 + // 메인 차트 데이터 교체 — 실시간 캔들 상태도 초기화 (새 데이터 기준으로 다시 누적) candlestickSeriesRef.current.setData(pd.candles); + liveCandleRef.current = null; + lastAppliedPriceRef.current = null; + lastAppliedVolumeRef.current = null; const mainLines = mainLineSeriesRef.current; if (mainLines.length === 5) { mainLines[0].setData(pd.bollinger.upper); @@ -329,6 +378,64 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad } }, []); + // 실시간 현재가를 마지막(또는 오늘) 캔들에 반영 — 차트 재생성 없이 series.update 만 호출 + const applyLivePrice = useCallback(() => { + const price = livePriceRef.current; + const series = candlestickSeriesRef.current; + const pd = processedDataRef.current as any; + if (!price || !(price > 0) || !series || !pd) return; + const candles = pd.candles as CandlestickData[]; + if (!candles || candles.length === 0) return; + const vol = liveVolumeRef.current; + if (price === lastAppliedPriceRef.current && vol === lastAppliedVolumeRef.current) return; + + const last = candles[candles.length - 1]; + const lastTime = String(last.time); + const code = stockCodeRef.current; + const us = !!code && isUsStock(code); + const today = todayInTz(us ? 'America/New_York' : 'Asia/Seoul'); + const p = periodRef.current; + + // 반영 방식 결정 — 해당 기간 캔들이 아니면 아무것도 하지 않는다 (과거 캔들 오염 방지) + let mode: 'update' | 'append' | null = null; + if (p === 'D') { + if (lastTime === today) mode = 'update'; + else if (lastTime < today && !us && isKrMarketOpen()) mode = 'append'; + } else if (p === 'W') { + if (weekStartOf(lastTime) && weekStartOf(lastTime) === weekStartOf(today)) mode = 'update'; + } else if (p === 'M') { + if (lastTime.slice(0, 7) === today.slice(0, 7)) mode = 'update'; + } + if (!mode) return; + + let lc = liveCandleRef.current; + if (!lc) { + lc = mode === 'update' + ? { time: lastTime, open: last.open, high: last.high, low: last.low } + : { time: today, open: price, high: price, low: price }; + liveCandleRef.current = lc; + } + // 장중 고가/저가 누적 (틱 사이 되돌림이 있어도 고저가 유지) + lc.high = Math.max(lc.high, price); + lc.low = Math.min(lc.low, price); + try { + series.update({ time: lc.time as any, open: lc.open, high: lc.high, low: lc.low, close: price }); + // 거래량: 당일 누적(ACML_VOL)이므로 일봉 오늘 캔들에만 반영 + if (p === 'D' && vol && vol > 0 && volumeSeriesRef.current) { + volumeSeriesRef.current.update({ + time: lc.time as any, + value: vol, + color: price >= lc.open ? 'rgba(239, 68, 68, 0.6)' : 'rgba(59, 130, 246, 0.6)', + }); + } + } catch (error: unknown) { + const err = error as Error | null; + console.warn('실시간 캔들 반영 실패:', err?.message); + } + lastAppliedPriceRef.current = price; + lastAppliedVolumeRef.current = vol ?? null; + }, []); + // 차트 초기 생성 (마운트 시 1회만) — 생성 직후 대기 중인 데이터 즉시 적용 const chartsCreatedRef = useRef(false); @@ -429,6 +536,7 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad // 차트 생성 완료 — 이미 대기 중인 processedData가 있으면 즉시 적용 if (processedDataRef.current) { applySeriesData(processedDataRef.current); + applyLivePrice(); } // Cleanup (컴포넌트 언마운트 시에만) @@ -450,30 +558,15 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad // 차트가 아직 생성되지 않았으면 건너뛰기 (생성 완료 시 자동 적용됨) if (!chartsCreatedRef.current) return; applySeriesData(processedData); - }, [processedData, applySeriesData]); + // setData 로 실시간 상태가 초기화됐으므로 현재가를 즉시 재적용 + applyLivePrice(); + }, [processedData, applySeriesData, applyLivePrice]); - // 실시간 현재가 — 마지막 캔들만 update() (차트 재생성 없음) - // ref 기반 polling: 리렌더 없이 500ms마다 최신 livePrice 반영 - const lastAppliedPriceRef = useRef(null); + // 실시간 현재가/거래량/기간 변경 — 마지막 캔들만 update() (차트 재생성 없음) useEffect(() => { - const id = setInterval(() => { - const price = livePriceRef.current; - if (!price || !candlestickSeriesRef.current || !processedData) return; - if (price === lastAppliedPriceRef.current) return; // 같은 가격이면 skip - const candles = processedData.candles; - if (!candles || candles.length === 0) return; - const last = candles[candles.length - 1]; - candlestickSeriesRef.current.update({ - time: last.time as any, - open: last.open, - high: Math.max(last.high, price), - low: Math.min(last.low, price), - close: price, - }); - lastAppliedPriceRef.current = price; - }, 500); - return () => clearInterval(id); - }, [processedData]); + if (!chartsCreatedRef.current) return; + applyLivePrice(); + }, [livePriceProp, liveVolume, period, stockCode, applyLivePrice]); return (
@@ -519,7 +612,11 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad
); }, (prevProps, nextProps) => { - // livePrice는 ref로 처리 — 리렌더 불필요 (비교 제외) + // 실시간 값 변경은 리렌더 필요 (effect 에서 series.update — 차트 재생성 없음, 초당 최대 1회) + if (prevProps.livePrice !== nextProps.livePrice) return false; + if (prevProps.liveVolume !== nextProps.liveVolume) return false; + if (prevProps.period !== nextProps.period) return false; + if (prevProps.stockCode !== nextProps.stockCode) return false; // data 배열의 내용이 같으면 리렌더 건너뛰기 (참조가 달라도) if (prevProps.data === nextProps.data) return true; if (!prevProps.data || !nextProps.data) return false; diff --git a/frontend/src/hooks/useMarketData.ts b/frontend/src/hooks/useMarketData.ts index 8092cdc..2a9ef2a 100644 --- a/frontend/src/hooks/useMarketData.ts +++ b/frontend/src/hooks/useMarketData.ts @@ -16,6 +16,11 @@ * @returns livePrices, livePricesRef, liveQuote, handleMarketUpdate */ import { useEffect, useState, useRef, useCallback } from 'react' +import { isKrMarketOpen } from '../utils/marketHours' +import { isUsStock } from '../utils/stockUtils' + +/** 최근 체결 수신 후 호가(bid1)로 가격을 덮지 않는 유예 시간 */ +const QUOTE_OVERRIDE_TICK_GRACE_MS = 10_000 export const useMarketData = ( selectedStock: string, @@ -29,6 +34,12 @@ export const useMarketData = ( const livePricesRef = useRef>({}) /** 가격 변경 여부 플래그 — flush interval에서 확인 */ const pricesDirtyRef = useRef(false) + /** 종목별 마지막 체결(tick) 수신 시각(ms) — 최근 체결이 있으면 호가 bid1 으로 가격을 덮지 않는다 */ + const lastTickAtRef = useRef>({}) + /** 종목별 당일 누적 거래량(tick ACML_VOL) — 차트 오늘 캔들 거래량 반영용 */ + const [liveVolumes, setLiveVolumes] = useState>({}) + const liveVolumesRef = useRef>({}) + const volumesDirtyRef = useRef(false) /** 선택 종목의 실시간 호가 데이터 (WebSocket quote 메시지) */ const [liveQuote, setLiveQuote] = useState(null) @@ -40,6 +51,10 @@ export const useMarketData = ( pricesDirtyRef.current = false setLivePrices({ ...livePricesRef.current }) } + if (volumesDirtyRef.current) { + volumesDirtyRef.current = false + setLiveVolumes({ ...liveVolumesRef.current }) + } }, 1000) return () => clearInterval(id) }, []) @@ -54,7 +69,17 @@ export const useMarketData = ( */ const handleMarketUpdate = useCallback((data: unknown) => { const d = data as any + // 국내 장외 시간엔 잔고(portfolio current_price)가 기준 — 시간외/잔여 틱이 잔고 동기화 값을 덮어쓰지 않도록 가격 캐시 갱신을 건너뛴다. + // (호가창 liveQuote 는 아래에서 계속 갱신) + const krClosedForCode = !!d?.stock_code && !isUsStock(d.stock_code) && !isKrMarketOpen() + if (krClosedForCode && d?._type === 'tick') return if (d?._type === 'tick' && d.stock_code && d.price != null) { + lastTickAtRef.current[d.stock_code] = Date.now() + const vol = Number(d.volume) + if (Number.isFinite(vol) && vol > 0 && liveVolumesRef.current[d.stock_code] !== vol) { + liveVolumesRef.current[d.stock_code] = vol + volumesDirtyRef.current = true + } // 체결가 변경 시에만 ref 업데이트 + dirty 플래그 설정 const prev = livePricesRef.current[d.stock_code] if (prev !== d.price) { @@ -64,8 +89,12 @@ export const useMarketData = ( } else if (d?._type === 'quote' && d.stock_code) { // 호가 데이터에서 현재가 추정 (bid1 = 최우선 매수호가) // tick 데이터가 없을 때 P&L 계산용 가격 업데이트 + // 최근 QUOTE_OVERRIDE_TICK_GRACE_MS 내 체결(tick)이 있으면 체결가가 기준 — bid1 으로 덮지 않는다 + // (매 호가마다 체결가↔매수호가가 번갈아 표시되며 가격이 흔들리던 문제) const bidPrice = d.bid1 as number | undefined - if (bidPrice && bidPrice > 0) { + const lastTickAt = lastTickAtRef.current[d.stock_code] ?? 0 + const recentTick = Date.now() - lastTickAt < QUOTE_OVERRIDE_TICK_GRACE_MS + if (bidPrice && bidPrice > 0 && !krClosedForCode && !recentTick) { const prev = livePricesRef.current[d.stock_code] if (prev !== bidPrice) { livePricesRef.current[d.stock_code] = bidPrice @@ -80,14 +109,20 @@ export const useMarketData = ( }, [selectedStock]) - // 포트폴리오 로드 시 초기 가격 설정 (WebSocket 연결 전 가격 폴백) + // 포트폴리오 로드 시 가격 설정 + // - 장중: 실시간 가격이 없는 종목만 잔고 가격으로 초기화 + // - 국내 장외: 잔고(KIS 동기화) 가격이 우선 — 이전 장중 틱 캐시를 덮어쓴다 (카드/잔고 불일치 방지) useEffect(() => { if (!portfolio) return let changed = false + const krOpen = isKrMarketOpen() portfolio.forEach((s: any) => { - // 아직 실시간 가격이 없는 종목만 초기화 - if (s.current_price && !livePricesRef.current[s.stock_code]) { - livePricesRef.current[s.stock_code] = Number(s.current_price) + const px = Number(s.current_price || 0) + if (!(px > 0)) return + const cur = livePricesRef.current[s.stock_code] + const overrideClosed = !isUsStock(s.stock_code) && !krOpen + if (overrideClosed ? cur !== px : !cur) { + livePricesRef.current[s.stock_code] = px changed = true } }) @@ -107,6 +142,8 @@ export const useMarketData = ( livePrices, /** 종목별 실시간 가격 ref (리렌더 없이 즉시 접근) */ livePricesRef, + /** 종목별 당일 누적 거래량 (state — 1초마다 갱신) */ + liveVolumes, /** 선택 종목 실시간 호가 데이터 */ liveQuote, /** WebSocket market_update 메시지 콜백 */ diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 2b9b828..2e9adcd 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -46,6 +46,8 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) => const pongTimeoutRef = useRef(null) const reconnectAttemptsRef = useRef(0) const mountedRef = useRef(true) + /** 마지막 onopen 시각 — 안정 연결(>=STABLE_MS) 판단용 */ + const openedAtRef = useRef(0) const socketRef = useRef(null) // 콜백을 ref로 유지 — connect 클로저가 재생성되지 않아도 최신 콜백 참조 const onMarketUpdateRef = useRef(options?.onMarketUpdate) @@ -80,7 +82,11 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) => try { const apiKey = localStorage.getItem('api_key') || '' - const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}?token=${encodeURIComponent(apiKey)}`) + // 빈 토큰은 서브프로토콜로 쓸 수 없고(SyntaxError) 어차피 인증 실패 — 연결 시도하지 않음 + if (!apiKey) return + // 토큰은 URL 쿼리(로그/히스토리에 남음) 대신 서브프로토콜로 전달: ["autostock.auth", token] + // 주의: 백엔드가 이 서브프로토콜을 지원해야 연결된다 — 백엔드와 같은 릴리스로 배포할 것. + const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}`, ['autostock.auth', apiKey]) // 즉시 socketRef에 할당 — onclose 가드가 null 상태를 보지 않도록 socketRef.current = ws @@ -89,7 +95,8 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) => if (socketRef.current !== ws) { ws.close(); return } console.log('🔗 WebSocket connected') setConnected(true) - reconnectAttemptsRef.current = 0 + // 백오프 리셋은 연결이 일정 시간 유지된 뒤에만 (열자마자 닫히는 서버에서 3초 폭주 방지) + openedAtRef.current = Date.now() lastPongReceivedRef.current = Date.now() // 30초마다 ping 전송 (Nginx/프록시 타임아웃 방지) @@ -155,6 +162,10 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) => if (socketRef.current !== null && socketRef.current !== ws) return console.log('🔌 WebSocket disconnected') + if (openedAtRef.current && Date.now() - openedAtRef.current >= 15000) { + reconnectAttemptsRef.current = 0 + } + openedAtRef.current = 0 setConnected(false) socketRef.current = null @@ -171,7 +182,8 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) => // 지수 백오프 재연결 (최대 30초 간격, 무제한 재시도) if (mountedRef.current) { const attempt = reconnectAttemptsRef.current - const delay = Math.min(baseDelay * Math.pow(1.5, attempt), 30000) + // 지수 백오프 + 지터(동시 재연결 폭주 방지) + const delay = Math.min(baseDelay * Math.pow(1.5, attempt), 30000) * (0.8 + Math.random() * 0.4) reconnectAttemptsRef.current += 1 console.log(`🔄 Reconnecting WebSocket in ${(delay / 1000).toFixed(1)}s (attempt ${attempt + 1})...`) reconnectTimeoutRef.current = setTimeout(() => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 45e24b9..548c20f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -34,6 +34,11 @@ function showOfflineIndicator(offline: boolean) { // 재시도 가능 여부 판단 function isRetryableError(error: AxiosError): boolean { + // 멱등하지 않은 요청(주문 POST/DELETE 등)은 절대 자동 재시도하지 않는다 — 타임아웃/5xx 후 재시도하면 중복 주문이 된다. + const method = (error.config?.method || 'get').toLowerCase() + if (method !== 'get' && method !== 'head') return false + // 타임아웃은 서버가 느린 것이므로 재시도가 부하만 키운다(15초 x 4회 폭주 방지) + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return false if (!error.response) { // 네트워크 오류 (재시도 가능) return true @@ -70,7 +75,7 @@ api.interceptors.request.use( // 재시도 카운트 초기화 (첫 요청인 경우) if (!config.retryCount) { config.retryCount = 0 - config.retryMaxCount = 3 + config.retryMaxCount = 2 } return config @@ -93,18 +98,18 @@ api.interceptors.response.use( if (!config.retryCount) { config.retryCount = 0 - config.retryMaxCount = 3 + config.retryMaxCount = 2 } // 재시도 가능 여부 확인 - const maxCount = config.retryMaxCount ?? 3 + const maxCount = config.retryMaxCount ?? 2 const shouldRetry = isRetryableError(error) && config.retryCount < maxCount if (shouldRetry) { config.retryCount += 1 - const delay = getBackoffDelay(config.retryCount - 1) + const delay = getBackoffDelay(config.retryCount - 1) * (0.8 + Math.random() * 0.4) console.log( `🔄 Retrying request (${config.retryCount}/${config.retryMaxCount}) ` + `after ${delay}ms: ${config.method?.toUpperCase()} ${config.url}` diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d220e80..519d259 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -10,11 +10,18 @@ const queryClient = new QueryClient({ queries: { refetchOnWindowFocus: false, // 배포(1~2분 다운) 후에도 화면이 스스로 살아나도록: - // - 재시도 3회(지수 백오프, 최대 10초) + // - 재시도 최대 2회(지수 백오프, 최대 30초) // - 네트워크 복구 시 자동 재조회 // - 오프라인 판단으로 쿼리가 멈추지 않도록 항상 실행 - retry: 3, - retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000), + // axios 인터셉터가 이미 GET 을 재시도(지수 백오프)했거나 타임아웃인 오류는 react-query 가 다시 재시도하지 않는다 + // (이전: 인터셉터 4회 x react-query 4회 = 요청 15~16회 연속 폭주) + retry: (failureCount, error) => { + const e = error as { config?: { retryCount?: number }; code?: string } + if (e?.code === 'ECONNABORTED' || e?.code === 'ETIMEDOUT') return failureCount < 1 + if ((e?.config?.retryCount ?? 0) > 0) return false + return failureCount < 2 + }, + retryDelay: (attempt) => Math.min(2000 * 2 ** attempt, 30000), refetchOnReconnect: true, networkMode: 'always', }, diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0050f47..5d0151d 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -39,6 +39,8 @@ import AccountSetupNotice from '../components/AccountSetupNotice' import MarketIndexBar from '../components/MarketIndexBar' import { CandleData, Position, TradeHistory } from '../types/trading' import { isUsStock } from '../utils/stockUtils' +import { isKrMarketOpen } from '../utils/marketHours' +import { parseKst } from '../utils/formatUtils' import './Dashboard.css' /** @@ -53,6 +55,25 @@ import './Dashboard.css' * * @returns 한국장 또는 미국장이 운영 중이면 true */ +/** 동일 차트 요청 중복 억제 창 */ +const CHART_REQ_DEDUPE_MS = 3000 +/** 장중 차트 재요청 주기 */ +const CHART_REFRESH_INTERVAL_MS = 60_000 +/** 차트 로딩 스피너 최대 표시 시간 */ +const CHART_LOADING_TIMEOUT_MS = 15_000 + +/** 차트 응답의 빈 데이터/샘플/오류 사유를 사용자 문구로 변환 */ +const describeChartEmptyReason = (reason: unknown, error: string | null, isSample: boolean): string | null => { + const r = typeof reason === 'string' ? reason : '' + const known: Record = { + overseas_chart_unavailable: '해외 종목 차트 미지원', + } + if (r) return known[r] ?? r + if (error) return error + if (isSample) return '실데이터 없음 — 샘플 데이터 표시 안 함' + return null +} + const checkMarketOpen = () => { const now = new Date(); const kstTime = new Date(now.toLocaleString("en-US", { timeZone: "Asia/Seoul" })); @@ -110,6 +131,13 @@ const Dashboard = () => { const chartDataRef = useRef(null) const [chartLoading, setChartLoading] = useState(false) const chartLoadingRef = useRef(false) + // chartData 가 어느 종목의 데이터인지 — 종목 전환 직후 옛 차트에 새 종목 실시간가가 적용되는 것을 막는다 + const [chartStock, setChartStock] = useState('') + const chartStockRef = useRef('') + // 차트 데이터가 없을 때 표시할 사유 (샘플 데이터·해외 미지원·오류) + const [chartEmptyReason, setChartEmptyReason] = useState(null) + // 마지막으로 보낸 차트 요청 중 응답을 기다리는 것 (silent=백그라운드 갱신) + const chartAwaitingRef = useRef<{ key: string; at: number; silent: boolean } | null>(null) // selectedStock / chartPeriod을 콜백 클로저에서 안전하게 참조하기 위한 ref const selectedStockRef = useRef('') const chartPeriodRef = useRef<'D' | 'W' | 'M'>('D') @@ -156,6 +184,7 @@ const Dashboard = () => { // Market data hook (with throttled live prices and market update handler) const { livePrices, + liveVolumes, liveQuote, handleMarketUpdate, } = useMarketData( @@ -163,28 +192,40 @@ const Dashboard = () => { portfolio ) - // chart_data 메시지 처리 함수 (wsMessages useEffect에서 호출) + // chart_data 메시지 처리 함수 (ws-chart-data CustomEvent 에서 호출) const processChartData = useCallback((m: Record) => { - console.log('📊 processChartData called', { - msgStock: m?.stock_code, - msgPeriod: m?.period, - refStock: selectedStockRef.current, - refPeriod: chartPeriodRef.current, - }) // 응답이 현재 선택 종목/기간과 일치할 때만 반영 (레이스 컨디션 방지) + // 오류 응답은 period 가 없을 수 있어 종목만 일치하면 수용 if ( m?.stock_code !== selectedStockRef.current || - m?.period !== chartPeriodRef.current + (m?.period != null && m.period !== chartPeriodRef.current) ) { - console.log('📊 processChartData SKIP — stock/period mismatch') return } - const rawData = (m.data as Record[]) || [] - if (rawData.length === 0) { - chartDataRef.current = null - setChartData(null) - chartLoadingRef.current = false - setChartLoading(false) + const key = `${selectedStockRef.current}|${chartPeriodRef.current}` + const awaiting = chartAwaitingRef.current + const silent = !!awaiting && awaiting.key === key && awaiting.silent + if (awaiting && awaiting.key === key) chartAwaitingRef.current = null + const clearLoading = () => { + if (chartLoadingRef.current) { + chartLoadingRef.current = false + setChartLoading(false) + } + } + + const rawData = Array.isArray(m.data) ? (m.data as Record[]) : [] + const isSample = m.is_sample === true + const errMsg = typeof m.error === 'string' && m.error ? m.error : null + if (rawData.length === 0 || isSample || errMsg) { + // 백그라운드 갱신이 실패한 경우엔 기존 실데이터를 유지한다 (일시 오류로 차트가 사라지지 않게) + const hasCurrent = !!chartDataRef.current && chartStockRef.current === selectedStockRef.current + if (!(silent && hasCurrent)) { + // 샘플(가짜) 캔들은 그리지 않는다 + chartDataRef.current = null + setChartData(null) + setChartEmptyReason(describeChartEmptyReason(m.reason, errMsg, isSample)) + } + clearLoading() return } const data = rawData.map((item: Record) => ({ @@ -195,21 +236,25 @@ const Dashboard = () => { close: Number(item.close), volume: Number(item.volume), })) - // 동일 데이터면 setState 건너뛰기 (차트 재생성 방지) + // 동일 종목·동일 데이터면 setState 건너뛰기 (차트 재생성 방지) const prev = chartDataRef.current - const isSame = prev && prev.length === data.length && + const isSame = prev && chartStockRef.current === selectedStockRef.current && + prev.length === data.length && prev.length > 0 && prev[0].date === data[0].date && prev[prev.length - 1].date === data[data.length - 1].date && - prev[prev.length - 1].close === data[data.length - 1].close + prev[prev.length - 1].close === data[data.length - 1].close && + prev[prev.length - 1].high === data[data.length - 1].high && + prev[prev.length - 1].low === data[data.length - 1].low && + prev[prev.length - 1].volume === data[data.length - 1].volume if (!isSame) { chartDataRef.current = data setChartData(data) + chartStockRef.current = selectedStockRef.current + setChartStock(selectedStockRef.current) } - if (chartLoadingRef.current) { - chartLoadingRef.current = false - setChartLoading(false) - } + setChartEmptyReason(null) + clearLoading() }, []) // WebSocket hook - market_update는 콜백, chart_data는 messages로 처리 @@ -370,49 +415,118 @@ const Dashboard = () => { }) // 차트 요청 헬퍼 — ref 기반으로 의존성 최소화 (리렌더 방지) - const requestChartRef = useRef((stock: string, period: string): boolean => { - console.log('📤 requestChart', { stock, period, wsConnected: wsConnectedRef.current }) - if (!stock || !wsConnectedRef.current) return false - sendMessageRef.current( - JSON.stringify({ - type: 'get_chart', - stock_code: stock, - period: period, - limit: 200, - }) - ) - console.log('📤 requestChart SENT') - return true - }) + // 동일 (종목, 기간) 요청은 3초 내 재전송하지 않는다 — WS 재연결 flapping 시 차트 요청 폭주 방지 + // 억제됐는데 직전 요청의 응답을 아직 못 받았다면(재연결로 유실 가능) 억제 창이 끝난 뒤 1회 재시도한다 + // → 억제 + 재연결이 겹쳐 로딩 스피너가 고착되던 문제(L-1) 방지 + const lastChartReqRef = useRef<{ key: string; at: number }>({ key: '', at: 0 }) + const chartRetryTimerRef = useRef | null>(null) + const requestChartRef = useRef( + (stock: string, period: string, silent = false): 'sent' | 'suppressed' | 'offline' => { + if (!stock || !wsConnectedRef.current) return 'offline' + const key = `${stock}|${period}` + const now = Date.now() + const elapsed = now - lastChartReqRef.current.at + if (lastChartReqRef.current.key === key && elapsed < CHART_REQ_DEDUPE_MS) { + const awaiting = chartAwaitingRef.current + if (awaiting && awaiting.key === key && !chartRetryTimerRef.current) { + chartRetryTimerRef.current = setTimeout(() => { + chartRetryTimerRef.current = null + const a = chartAwaitingRef.current + if (!a || a.key !== key) return // 그 사이 응답 도착 + if (`${selectedStockRef.current}|${chartPeriodRef.current}` !== key) return + requestChartRef.current(stock, period, a.silent) + }, CHART_REQ_DEDUPE_MS - elapsed + 50) + } + return 'suppressed' + } + lastChartReqRef.current = { key, at: now } + chartAwaitingRef.current = { key, at: now, silent } + sendMessageRef.current( + JSON.stringify({ + type: 'get_chart', + stock_code: stock, + period: period, + limit: 200, + }) + ) + return 'sent' + } + ) // ref 내용은 항상 최신 — useCallback 불필요 (의존성 변경에 의한 리렌더 차단) + /** 차트 요청 + 로딩 표시. 억제됐어도 응답 대기 중이면 로딩 유지(재시도 예약됨), 아니면 로딩 해제 */ + const startChartRequest = useCallback((stock: string, period: 'D' | 'W' | 'M', showLoading: boolean) => { + const r = requestChartRef.current(stock, period) + const key = `${stock}|${period}` + const pending = r === 'sent' || (r === 'suppressed' && chartAwaitingRef.current?.key === key) + const want = showLoading && pending + if (chartLoadingRef.current !== want) { + chartLoadingRef.current = want + setChartLoading(want) + } + }, []) + + // 언마운트 시 재시도 타이머 정리 + useEffect(() => () => { + if (chartRetryTimerRef.current) clearTimeout(chartRetryTimerRef.current) + }, []) + // 차트 데이터 — selectedStock / chartPeriod 변경 시 요청 useEffect(() => { + setChartEmptyReason(null) if (!selectedStock) { chartDataRef.current = null setChartData(null) + chartStockRef.current = '' + setChartStock('') chartLoadingRef.current = false setChartLoading(false) return } - const sent = requestChartRef.current(selectedStock, chartPeriod) - if (sent) { - chartLoadingRef.current = true - setChartLoading(true) - } - }, [selectedStock, chartPeriod]) + // 오프라인이면 (재)연결 effect 가 요청한다 — 그때까지는 로딩 표시 안 함 + startChartRequest(selectedStock, chartPeriod, true) + }, [selectedStock, chartPeriod, startChartRequest]) // WebSocket (재)연결 시 — 차트 데이터 요청 useEffect(() => { if (!selectedStock || !wsConnected) return - // 이미 데이터가 있으면 로딩 표시 안 함 (조용히 갱신) - if (!chartDataRef.current) { - chartLoadingRef.current = true - setChartLoading(true) - } - requestChartRef.current(selectedStock, chartPeriod) + // 이미 이 종목 데이터가 있으면 로딩 표시 안 함 (조용히 갱신) + const hasCurrent = !!chartDataRef.current && chartStockRef.current === selectedStock + startChartRequest(selectedStock, chartPeriod, !hasCurrent) }, [wsConnected]) // eslint-disable-line react-hooks/exhaustive-deps + // 로딩 워치독 — 응답이 끝내 오지 않으면 스피너를 내리고 사유를 표시 (무한 스피너 방지) + useEffect(() => { + if (!chartLoading) return + const id = setTimeout(() => { + if (!chartLoadingRef.current) return + chartLoadingRef.current = false + setChartLoading(false) + chartAwaitingRef.current = null + if (!chartDataRef.current) setChartEmptyReason('응답 시간 초과 — 잠시 후 다시 시도') + }, CHART_LOADING_TIMEOUT_MS) + return () => clearTimeout(id) + }, [chartLoading]) + + // 장중 차트 주기 갱신 (H-2) — 국내 종목 + 국내 정규장 + 보이는 탭에서만 60초마다 조용히 재요청 + // (실시간 현재가는 마지막 캔들만 움직이므로, 지표·거래량·전일 대비 정합을 위해 원본을 주기적으로 다시 받는다) + useEffect(() => { + if (!selectedStock || isUsStock(selectedStock)) return + const refresh = () => { + if (document.visibilityState !== 'visible') return + if (!isKrMarketOpen()) return + requestChartRef.current(selectedStockRef.current, chartPeriodRef.current, true) + } + const id = setInterval(refresh, CHART_REFRESH_INTERVAL_MS) + // 탭이 다시 보이면 즉시 1회 갱신 (숨김 동안은 interval 이 요청하지 않음) + const onVisible = () => { if (document.visibilityState === 'visible') refresh() } + document.addEventListener('visibilitychange', onVisible) + return () => { + clearInterval(id) + document.removeEventListener('visibilitychange', onVisible) + } + }, [selectedStock]) + // 거래 이력 - 30초 (자동매매 체결 감지용) const { data: tradeHistory } = useQuery({ queryKey: ['trade-history', tenantMode], @@ -424,7 +538,10 @@ const Dashboard = () => { ).data as TradeHistory[] const seen = new Set() - return data + // 서버 정렬에 의존하지 않고 최신순 정렬 후 20건 (id 기반 신규 체결 감지도 최신 기준 유지) + const ts = (t: TradeHistory) => parseKst(t.created_at || t.executed_at || '')?.getTime() ?? 0 + return [...data] + .sort((x: TradeHistory, y: TradeHistory) => ts(y) - ts(x) || Number(y.id) - Number(x.id)) .filter((trade: TradeHistory) => { if (seen.has(trade.id)) return false seen.add(trade.id) @@ -526,6 +643,26 @@ const Dashboard = () => { const liveSummary = useMemo(() => { if (!portfolioSummary || !portfolio) return portfolioSummary + // 국내 장외 시간 또는 해외 보유(통화 혼재)가 있으면 클라이언트 재계산을 하지 않고 서버 값을 그대로 쓴다. + // (가격 소스 불일치 방지 - 잔고 동기화 값이 기준. 누락된 파생 필드만 같은 소스(portfolio.current_price)로 채움) + if (!isKrMarketOpen() || portfolio.some((s) => isUsStock(s.stock_code))) { + const sv = portfolioSummary as Record + const valueFallback = portfolio.reduce( + (sum, s) => sum + Number(s.current_price || 0) * Number(s.quantity || 0), 0) + const costFallback = portfolio.reduce( + (sum, s) => sum + Number(s.average_price || 0) * Number(s.quantity || 0), 0) + const tv = sv.total_value ?? valueFallback + const ti = sv.total_investment ?? costFallback + const tpl = sv.total_profit_loss ?? (tv - ti) + return { + ...portfolioSummary, + total_value: tv, + total_investment: ti, + total_profit_loss: tpl, + total_profit_loss_rate: sv.total_profit_loss_rate ?? (ti > 0 ? (tpl / ti) * 100 : 0), + } + } + // live 현재가 기반 주식 평가금액 // 가격 결손 종목(WS tick도 없고 DB current_price도 0/NaN)을 별도 수집 — // 이런 종목이 있으면 liveValue 자체가 축소 계산되어 있어 보정에 쓸 수 없다. @@ -711,6 +848,9 @@ const Dashboard = () => { }, [tradeHistory]) const livePrice = livePrices[selectedStock] + // 차트에는 chartData 가 선택 종목의 데이터일 때만 실시간가 적용 (종목 전환 중 옛 차트 오염 방지 — M-3) + const chartLivePrice = chartStock === selectedStock ? livePrice : undefined + const chartLiveVolume = chartStock === selectedStock ? liveVolumes[selectedStock] : undefined // 실시간 호가: WebSocket quote(H0STASP0) → REST 초기값 폴백 const activeOrderbook = useMemo(() => { @@ -855,6 +995,9 @@ const Dashboard = () => { }, enabled: !!selectedStock, staleTime: 30000, + // 장애 시 재시도/재연결 재조회로 요청이 반복되지 않게 (queryFn 이 오류를 결과로 흡수함) + retry: false, + refetchOnReconnect: false, }); // useCallback으로 안정화 → 패널에 전달 시 매 렌더마다 새 함수 생성 방지 @@ -929,6 +1072,7 @@ const Dashboard = () => { sellDisabled={!tradeActionsReady} accountSetupRequired={accountSetupRequired || accountStatus.required} accountSetupMessage={accountSetupMessage || accountStatus.message} + exchangeRate={portfolioSummary?.exchange_rate_unavailable === true ? 0 : (Number(portfolioSummary?.exchange_rate) || 0)} />
@@ -942,7 +1086,9 @@ const Dashboard = () => { chartPeriod={chartPeriod} onPeriodChange={setChartPeriod} stockName={stockName} - livePrice={livePrice} + livePrice={chartLivePrice} + liveVolume={chartLiveVolume} + emptyReason={chartEmptyReason} /> {/* 하단: AI 투자 분석 & 호가창 diff --git a/frontend/src/pages/MCPStatus.tsx b/frontend/src/pages/MCPStatus.tsx index 35783a7..b405675 100644 --- a/frontend/src/pages/MCPStatus.tsx +++ b/frontend/src/pages/MCPStatus.tsx @@ -6,7 +6,9 @@ import { useEffect, useState } from 'react' import HtsPage from '../components/HtsPage' const MCPStatus = () => { - const { messages } = useWebSocket('mcp-monitor') + // 탭별 고유 client_id: 서버가 client_id 를 인증 사용자에 바인딩하므로 고정 id 는 두 번째 사용자를 거부시킨다 + const [wsClientId] = useState(() => `mcp-monitor_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`) + const { messages } = useWebSocket(wsClientId) const [mcpMessages, setMcpMessages] = useState([]) // MCP 서버 상태 조회 diff --git a/frontend/src/pages/TradeHistory.tsx b/frontend/src/pages/TradeHistory.tsx index db9c86d..187f28b 100644 --- a/frontend/src/pages/TradeHistory.tsx +++ b/frontend/src/pages/TradeHistory.tsx @@ -56,6 +56,19 @@ const fmtPrice = (v: number | null | string) => { return n == null ? '-' : fmtKRW(n) } +/** 체결가 우선 표시. 체결가가 없으면 '체결 확인 중' + 회색 주문가 */ +const PriceCell = ({ executed, order }: { executed: number | null; order: number | null }) => { + if (executed != null && Number(executed) > 0) return <>{fmtPrice(executed)} + return ( + + 체결 확인 중 + {order != null && Number(order) > 0 && ( + 주문가 {fmtPrice(order)} + )} + + ) +} + // ── 종목별 뷰: 매수→매도 페어 매칭 ────────────────────────────── interface TradePair { @@ -151,7 +164,7 @@ function PairView({ pairs }: { pairs: TradePair[] }) { {format(toKST(p.buy.created_at), 'MM/dd HH:mm', { locale: ko })} - {fmtPrice(p.buy.executed_price ?? p.buy.order_price)} + {isOpen @@ -160,7 +173,7 @@ function PairView({ pairs }: { pairs: TradePair[] }) { } - {isOpen ? '-' : fmtPrice(p.sell!.executed_price ?? p.sell!.order_price)} + {isOpen ? '-' : } {(p.buy.executed_quantity ?? p.buy.order_quantity)?.toLocaleString()}주 diff --git a/frontend/src/types/trading.ts b/frontend/src/types/trading.ts index fdb1b6d..b1a4259 100644 --- a/frontend/src/types/trading.ts +++ b/frontend/src/types/trading.ts @@ -70,6 +70,16 @@ export interface Position { exchange_code?: string; currency_code?: string; strategy_type?: string; + /** 스윙 전략 기준 평단·수량·손익 (합산 행에서 DT 분 분리, 백엔드 확정 필드) */ + swing_avg_price?: number; + swing_quantity?: number; + /** active_trade | conservative_max | portfolio_avg | derived_from_mixed | none */ + swing_avg_price_basis?: string; + swing_profit_loss?: number; + swing_profit_loss_rate?: number; + dt_quantity?: number; + dt_avg_price?: number; + // avg_price_mixed(boolean): 합산 평단에 스윙·DT 가 섞여 있어 분리했음 — 인덱스 시그니처로 접근(`!!stock.avg_price_mixed`) [key: string]: string | number | undefined; } @@ -96,7 +106,19 @@ export interface PortfolioSummary { overseas_value_usd?: number; overseas_value_krw?: number; exchange_rate?: number; - [key: string]: string | number | undefined; + /** 총자산 기준 현금 = 총자산 − 주식평가 (화면에서 총자산 = 주식 + 예수금 이 맞도록) */ + cash_for_total_assets?: number; + /** 주문가능현금 */ + orderable_cash?: number; + /** 결제 기준 예수금 */ + settlement_cash?: number; + /** 현금 기준 간 차이 (진단용) */ + cash_basis_gap?: number; + exchange_rate_source?: string; + /** true 면 환율 미수신 — 해외 원화 환산하지 않음 */ + exchange_rate_unavailable?: boolean; + fx_conversion_skipped?: boolean; + [key: string]: string | number | boolean | undefined; } export interface PortfolioCombined { @@ -139,7 +161,16 @@ export interface RecommendedStock { rsi?: number | null; stochastic_k?: number | null; score?: number; - [key: string]: string | number | null | string[] | undefined; + match_source?: 'rule' | 'fallback_high_score' | string; + match_source_label?: string; + block_reasons?: string[]; + buy_allowed?: boolean; + ml_blocks_buy?: boolean; + ml_signal?: string | null; + price_source?: 'realtime' | 'batch' | string; + batch_current_price?: number | null; + batch_change_rate?: number | null; + [key: string]: string | number | boolean | null | string[] | undefined; } export interface Recommendation { @@ -152,6 +183,8 @@ export interface RecommendationsData { analyzed_stock_list: RecommendedStock[]; recommendations: Recommendation[]; batch_time?: string; + batch_date?: string; + realtime_overlay_count?: number; trades_executed?: Trade[]; recommendations_by_rule?: Array<{ rule_name: string; diff --git a/frontend/src/utils/formatUtils.ts b/frontend/src/utils/formatUtils.ts index 445b04d..9e6a0e3 100644 --- a/frontend/src/utils/formatUtils.ts +++ b/frontend/src/utils/formatUtils.ts @@ -14,3 +14,19 @@ export const formatTime = (isoStr?: string): string | null => { const d = new Date(isoStr) return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' }) } + +/** 타임존 없는 ISO(서버 KST naive)는 +09:00 보정 */ +export const parseKst = (raw?: string | null): Date | null => { + if (!raw) return null + const hasTz = /([zZ]|[+-]\d{2}:?\d{2})$/.test(raw) + const d = new Date(hasTz ? raw : raw + '+09:00') + return Number.isNaN(d.getTime()) ? null : d +} + +/** 가격 포맷: 원화는 정수 반올림, USD 는 소수 2자리 */ +export const formatPriceByMarket = (price: number, isUs: boolean): string => { + if (isUs) { + return `$${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + } + return `${Math.round(price).toLocaleString('ko-KR')}원` +} diff --git a/frontend/src/utils/marketHours.ts b/frontend/src/utils/marketHours.ts new file mode 100644 index 0000000..90cb62e --- /dev/null +++ b/frontend/src/utils/marketHours.ts @@ -0,0 +1,36 @@ +/** + * KRX 휴장일(YYYY-MM-DD, KST). 주말은 별도 판정하므로 평일 휴장일만 둔다. + * 정적 표이므로 연 1회 갱신 필요. 백엔드 is_krx_holiday 와 어긋나면 setKrHolidays 로 덮어쓴다. + * 추정: 2026-07-17(제헌절)은 백엔드 holidays 라이브러리와 KRX 공지가 다를 수 있어 넣지 않았다. + */ +const DEFAULT_KR_HOLIDAYS: readonly string[] = [ + '2026-01-01', '2026-02-16', '2026-02-17', '2026-02-18', '2026-03-02', + '2026-05-01', '2026-05-05', '2026-05-25', '2026-06-03', '2026-08-17', + '2026-09-24', '2026-09-25', '2026-10-05', '2026-10-09', '2026-12-25', '2026-12-31', + '2027-01-01', +] + +let krHolidays: Set = new Set(DEFAULT_KR_HOLIDAYS) + +/** 백엔드 등에서 받은 휴장일 목록으로 교체(기본 표에 추가 병합). */ +export const setKrHolidays = (dates: readonly string[]): void => { + krHolidays = new Set([...DEFAULT_KR_HOLIDAYS, ...dates]) +} + +const pad = (n: number): string => String(n).padStart(2, '0') + +/** 해당 시각의 KST 날짜가 평일 휴장일(공휴일/KRX 휴장)인가. */ +export const isKrHoliday = (now: Date = new Date()): boolean => { + const kst = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' })) + return krHolidays.has(`${kst.getFullYear()}-${pad(kst.getMonth() + 1)}-${pad(kst.getDate())}`) +} + +/** 한국 정규장(09:00~15:30 KST, 평일, 휴장일 제외) 여부. */ +export const isKrMarketOpen = (now: Date = new Date()): boolean => { + const kst = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' })) + const day = kst.getDay() + if (day === 0 || day === 6) return false + if (isKrHoliday(now)) return false + const m = kst.getHours() * 60 + kst.getMinutes() + return m >= 540 && m <= 930 +} diff --git a/frontend/src/utils/portfolioGrouping.ts b/frontend/src/utils/portfolioGrouping.ts index 03dd7e8..73b8445 100644 --- a/frontend/src/utils/portfolioGrouping.ts +++ b/frontend/src/utils/portfolioGrouping.ts @@ -3,17 +3,19 @@ * * 우선순위: * 1. DT 엔진 인메모리 포지션(/daytrading/positions) 수량 -> 데이트레이딩 - * 2. 행의 strategy_type: daytrading/day_trade -> 데이트레이딩(엔진 수량을 모를 때 행 전체) + * 2. 행의 strategy_type: daytrading/day_trade 인데 엔진 수량이 없으면 -> 미관리(엔진 외) 보유 그룹(반드시 표시) * external/manual -> 수동/외부, us_swing -> 해외, swing/NULL -> 스윙 * Portfolio 는 stock_code UNIQUE 라 스윙+DT 수량이 합산돼 있으므로 DT 엔진 수량을 빼서 나눈다. */ import { Position } from '../types/trading' -export type PortfolioGroupKey = 'daytrading' | 'swing' | 'manual' | 'overseas' +export type PortfolioGroupKey = 'daytrading' | 'swing' | 'manual' | 'overseas' | 'unmanaged' export interface GroupedHolding extends Position { /** 합산 행에서 DT 수량을 알 수 없어 DT 가 포함됐을 수 있음 */ dtIncluded?: number + /** 엔진이 관리하지 않는 DT 잔고(엔진 인메모리에 없음) — 자동 청산 대상 아님 */ + unmanaged?: number /** 스윙+DT 합산 행에서 분할된 카드(1) — 수동매도는 DT 몫부터 차감되므로 청산 비활성 */ isSplit?: number } @@ -39,7 +41,7 @@ export function groupHoldings( holdings: Position[] | undefined, dtPositions: Position[] | undefined, ): PortfolioGroups { - const groups: PortfolioGroups = { daytrading: [], swing: [], manual: [], overseas: [] } + const groups: PortfolioGroups = { daytrading: [], swing: [], manual: [], overseas: [], unmanaged: [] } const dtKnown = dtPositions !== undefined const dtQty = new Map() for (const d of dtPositions ?? []) { @@ -64,8 +66,12 @@ export function groupHoldings( if (rest > 0) groups[kind === 'daytrading' ? 'swing' : kind].push({ ...row, quantity: rest, isSplit: 1 }) continue } - if (kind === 'daytrading') { - groups.daytrading.push(row) + if (kind === 'daytrading' && !dtKnown) { + // 엔진 조회 로딩/실패 — 관리 여부 불명. '미관리' 로 단정하지 않고 DT 포함 배지(청산 잠금)로 표시 + groups.swing.push({ ...row, dtIncluded: 1 }) + } else if (kind === 'daytrading') { + // 엔진 조회가 확정됐는데 인메모리에 없음 -> 엔진이 청산하지 않는 고아 잔고. 숨기지 말고 경고 그룹으로. + groups.unmanaged.push({ ...row, unmanaged: 1 }) } else if (kind === 'swing' && !dtKnown) { // 엔진 수량을 알 수 없는 스윙 행 — DT 가 합산됐을 수 있음 groups.swing.push({ ...row, dtIncluded: 1 }) diff --git a/scripts/health_watch.py b/scripts/health_watch.py new file mode 100644 index 0000000..9205b90 --- /dev/null +++ b/scripts/health_watch.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""/health 주기 감시 (NAS cron: 평일 장중 5분). 표준 라이브러리만 사용. + + */5 9-15 * * 1-5 python3 /path/scripts/health_watch.py (KST 기준 cron) + +판정: status != healthy / 스케줄러 alive=False·stale / db_worker alive=False / dlq.manual>0 / + 응답 불가(연결 실패·비JSON) 가 FAIL_THRESHOLD 회 연속이면 텔레그램 경보(상태 전이 시 1회, 복구 시 1회). +환경변수: HEALTH_URL(기본 http://127.0.0.1:8000/health), TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, + HEALTH_WATCH_STATE(기본 ~/.cache/auto-stock/health_watch.json), HEALTH_WATCH_FAILS(기본 2) +텔레그램 미설정이면 stderr 에 ERROR 를 남기고 exit 1 (cron 메일/로그로 식별). +종료코드: 0 정상, 1 이상(현재 회차 기준). +""" +import json +import os +import sys +import urllib.parse +import urllib.request + + +def problems_v1(health) -> list: + """/health 응답 dict 에서 문제 목록을 만든다(순수 함수).""" + if not isinstance(health, dict): + return ["응답이 JSON 객체가 아님"] + out = [] + if health.get("status") != "healthy": + out.append(f"status={health.get('status')}") + for name, v in (health.get("schedulers") or {}).items(): + if not v.get("alive"): + out.append(f"scheduler {name} 사망") + elif v.get("stale"): + out.append(f"scheduler {name} stale({v.get('last_cycle_age')}s)") + dbw = health.get("db_worker") or {} + if "alive" in dbw and not dbw.get("alive"): + out.append("db_worker 사망") + if ((health.get("dlq") or {}).get("manual") or 0) > 0: + out.append(f"DLQ manual={health['dlq']['manual']}") + return out + + +def next_state_v1(state: dict, problems: list, threshold: int): + """(새 상태, 행동) — 행동: None | 'alert' | 'recover'.""" + fails = int(state.get("fails", 0)) + alerted = bool(state.get("alerted", False)) + if problems: + fails += 1 + if fails >= threshold and not alerted: + return {"fails": fails, "alerted": True}, "alert" + return {"fails": fails, "alerted": alerted}, None + if alerted: + return {"fails": 0, "alerted": False}, "recover" + return {"fails": 0, "alerted": False}, None + + +def _send_telegram(text: str) -> bool: + token, chat = os.environ.get("TELEGRAM_BOT_TOKEN", ""), os.environ.get("TELEGRAM_CHAT_ID", "") + if not token or not chat: + print("ERROR: TELEGRAM_BOT_TOKEN/CHAT_ID 미설정 — 경보 전송 불가", file=sys.stderr) + return False + data = urllib.parse.urlencode({"chat_id": chat, "text": text}).encode() + try: + with urllib.request.urlopen(f"https://api.telegram.org/bot{token}/sendMessage", data=data, timeout=10) as r: + return r.status == 200 + except Exception as e: # 전송 실패는 경보 재시도를 위해 False 로 보고 + print(f"ERROR: 텔레그램 전송 실패: {type(e).__name__}", file=sys.stderr) + return False + + +def _fetch(url: str): + try: + with urllib.request.urlopen(url, timeout=10) as r: + return json.loads(r.read().decode()) + except Exception as e: + return {"__error__": f"/health 조회 실패: {type(e).__name__}"} + + +def main() -> int: + url = os.environ.get("HEALTH_URL", "http://127.0.0.1:8000/health") + threshold = int(os.environ.get("HEALTH_WATCH_FAILS", "2")) + path = os.environ.get("HEALTH_WATCH_STATE", os.path.expanduser("~/.cache/auto-stock/health_watch.json")) + h = _fetch(url) + probs = [h["__error__"]] if isinstance(h, dict) and "__error__" in h else problems_v1(h) + try: + with open(path) as f: + state = json.load(f) + except (OSError, ValueError): + state = {} + new, action = next_state_v1(state, probs, threshold) + if action == "alert": + if not _send_telegram("[auto-stock] /health 이상 연속 " + str(new["fails"]) + "회: " + "; ".join(probs)): + new["alerted"] = False # 전송 실패 → 다음 회차에 재시도 + elif action == "recover": + _send_telegram("[auto-stock] /health 복구") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(new, f) + for p in probs: + print("PROBLEM:", p, file=sys.stderr) + return 1 if probs else 0 + + +if __name__ == "__main__": + sys.exit(main())