From 3ae6b518ba80534af9d00575804067712507f528 Mon Sep 17 00:00:00 2001 From: easygap <103491329+easygap@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:56:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EC=A2=85=EA=B0=80=20=EC=88=98=EC=A7=91=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=ED=9B=84=20=EB=88=84=EB=9D=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=91=9C=EC=8B=9C=EB=90=98=EB=8A=94=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/daily_observation.py | 36 ++++++++++-- docs/RESEARCH_OPERATIONS_20261013.md | 18 ++++++ tests/test_daily_observation.py | 87 ++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/core/daily_observation.py b/core/daily_observation.py index 96dea0a..8fb1b10 100644 --- a/core/daily_observation.py +++ b/core/daily_observation.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone import json import os from pathlib import Path @@ -55,6 +55,31 @@ def _save(report, directory): temporary.unlink(missing_ok=True) +def _checked_close_session(report): + """Honor an early successful close without rewriting its saved attempt time.""" + expected = report.get("expected_close_session", "") + try: + job = report["jobs"]["close"] + result = job["result"] + coverage = result["coverage"] + session = result["last_completed_session"] + count = coverage["expected"] + if (job["status"] == job["data_status"] == "completed" and job["returncode"] == 0 + and session == result["expected_session"] == coverage["session"] + and coverage["status"] == "complete" + and type(count) is int and count > 0 + and coverage["present"] == count and coverage["missing"] == 0 + and len(coverage["baskets"]) == count + and all(row["status"] == "present" and row["close_count"] > 0 + and row["latest_close_session"] == session for row in coverage["baskets"]) + and date.fromisoformat(session).isoformat() == session + and session <= datetime.fromisoformat(report["updated_at"]).astimezone(KST).date().isoformat()): + return max(expected, session) + except (KeyError, TypeError, ValueError): + pass # Incomplete/old child reports cannot establish a newer close. + return expected + + def read_daily_observation(output_dir=None, *, now=None): """Read saved execution status without starting a collector or opening a DB.""" try: @@ -67,9 +92,11 @@ def read_daily_observation(output_dir=None, *, now=None): raise ValueError("invalid daily observation report") if report["status"] == "running" and current - stamp > timedelta(minutes=20): return {**report, "status": "interrupted", "issues": ["일일 관측 실행의 완료 기록이 없습니다."]} - if report.get("expected_close_session", "") < _expected_session(current): - return {**report, "status": "stale", "issues": ["새 거래일의 종가 관측 실행이 필요합니다."]} - return report + checked_session = _checked_close_session(report) + if checked_session < _expected_session(current): + return {**report, "expected_close_session": checked_session, + "status": "stale", "issues": ["새 거래일의 종가 관측 실행이 필요합니다."]} + return {**report, "expected_close_session": checked_session} except FileNotFoundError: return {"status": "not_started", "issues": ["아직 종가·수집 통합 실행 기록이 없습니다."]} except (OSError, ValueError, TypeError, KeyError): @@ -152,6 +179,7 @@ def run_daily_observation(output_dir=None): if report["status"] == "completed" and report["jobs"]["close"]["status"] == "awaiting_close": report["status"] = "awaiting_close" report["updated_at"] = _now().isoformat() + report["expected_close_session"] = _checked_close_session(report) _save(report, directory) # One small audit record per attempt; no raw logs or database copies. runs = directory / "runs" diff --git a/docs/RESEARCH_OPERATIONS_20261013.md b/docs/RESEARCH_OPERATIONS_20261013.md index 67a1cc9..23148cb 100644 --- a/docs/RESEARCH_OPERATIONS_20261013.md +++ b/docs/RESEARCH_OPERATIONS_20261013.md @@ -65,6 +65,24 @@ Python 관련 검사 108개(기존 수집·검증 82개, 누적 성과 26개), JavaScript 45개를 통과했다. 운영 DB와 기존 이벤트 69개는 조회 전후 해시가 같았다. 실제 브라우저에서 누적값·종료 버전·비교 보류 표시와 폭 390px의 요약 화면을 확인했다. 브라우저 창 최소화로 스크린샷은 남기지 못했으며 화면 구조·표시 문구 검증과 구분한다. 자동 갱신 후 계좌별 펼침 상태 보존도 검사했다. +## 추가 점검과 개선 우선순위 (2026-10-22~23) + +**종가 수집 완료 후 누락으로 표시되던 오류를 수정했다.** 10월 22일 16:27 실행은 두 바스켓의 당일 종가를 모두 저장했지만, 통합 보고서의 기준일은 실행 시작 시각에 따라 10월 21일로 남았다. 조회 함수는 16:30 이후 이 값을 보고 새 거래일의 관측이 필요하다고 잘못 판단했다. 운영 DB를 열지 않는 조회 함수에 실제 저장 보고서를 입력해 같은 현상을 재현했다. + +이제 종가 작업의 정상 종료, 완료 거래일, 전체 바스켓의 저장 여부가 일치하면 확인된 완료일을 기준으로 판단한다. 기존 보고서도 읽을 때 같은 기준을 적용하며 원본 파일은 다시 쓰지 않는다. 일부 바스켓 누락, 서로 다른 기준일, 미래 날짜, 별도 연구 수집 오류는 정상으로 바꾸지 않는다. 다음 거래일의 수집 시한이 지나면 새 누락도 계속 감지한다. 매매 코드와 검증 프로토콜은 바뀌지 않아 기존 검증 묶음이 유지된다. + +10월 23일 오전 실행에서 전일 종가 두 건과 같은 날짜 비교지수를 확인했다. 두 바스켓 모두 누적 종가 8개·수익 구간 7개가 유지되며 현재 버전은 종가 2개·수익 구간 1개다. 아직 장기 성과나 변경 효과를 판정할 표본은 부족하다. + +관련 회귀 검사 87개를 통과했고, 마지막 조회 결과 보완 후 일일 관측 검사 27개를 다시 통과했다. 실제 보고서의 원본 보존, 운영 DB와 기존 관측 이벤트의 해시 일치, 기존 검증 묶음 유지도 확인했다. 최종 검증 자료는 `output/daily_observation_review_20261023/verification.json`에 보존한다. + +다음 항목은 이번에 구현한 기능이 아니라, 코드와 저장 자료로 확인한 후속 과제다. + +1. **배당·분할 원장:** `core/basket_close.py`는 매매로 수량을 복원하고 `CashFlow`를 외부 입출금으로 처리한다. 배당을 입금으로만 기록하면 수익에서 빠지고, 분할로 바뀐 수량은 매매 이력과 맞지 않을 수 있다. 권리일·지급일·분할비율·배당·원천징수 등을 별도 이벤트로 남기고 평가에 반영해야 한다. 평가용 원시가격과 연구용 수정가격도 구분한다. +2. **달력 변경 근거 보존:** `core/paper_performance.py`는 두 관측의 전체 휴장일 해시가 다르면 연결을 보류한다. 미래 휴일인 `2027-01-01`만 추가해도 기존 10월 14~15일 성과가 끊기는 사례를 메모리에서 재현했다. 달력 버전별 내용을 먼저 보존하고 해당 평가 구간의 거래일 해석이 같은지 비교해야 한다. 과거 달력을 확인할 근거 없이 해시 검사만 없애지는 않는다. +3. **실측 체결비용:** 10월 22일 점검 기준 비용 관측 12회 중 체결 4회는 모두 모의 체결이며, 실제 증권사 체결·수수료 표본은 0개다. 체결 통보와 비용 명세서, 독립적인 주문 도착가격, 미체결 잔량의 평가가격을 연결하고 실측·추정 표본 수를 구분해야 한다. 이를 위해 실계좌 매매를 켜지는 않는다. +4. **수급의 비교 가능성:** 10월 22일 점검 당시 수급은 누적 11거래일·고유 99행이었다. 현재 순매수 주식 수를 거래량 등으로 정규화하고 업종별 비교와 5일·20일 지속성을 후보 근거로 연결할 수 있다. 20일 관측이 쌓이기 전에는 해당 지표를 자료 부족으로 표시해야 한다. +5. **동일 기간의 버전 비교:** 지금의 변경 전후 비교는 서로 다른 시장 기간을 비교한다. 기존 버전과 새 버전의 판단을 동일한 시점·자료로 병행 기록하는 모의 비교 경로가 필요하다. 실제 주문과 분리하고 당시 이용 가능했던 자료만 사용해야 한다. + ## 실행 명령 프로젝트 루트 PowerShell에서 실행한다. 수집 도구는 운영 장부에 주문이나 입출금을 기록하지 않는다. diff --git a/tests/test_daily_observation.py b/tests/test_daily_observation.py index ee22e81..5d17da3 100644 --- a/tests/test_daily_observation.py +++ b/tests/test_daily_observation.py @@ -6,6 +6,8 @@ from types import SimpleNamespace from unittest.mock import Mock +import pytest + from core import daily_observation as daily @@ -74,6 +76,91 @@ def kst(value): assert daily._expected_session(kst("2026-10-19T16:30")) == "2026-10-19" +def early_completed_report(): + # Mirrors the October 22 run: a confirmed close at 16:27, before the 16:30 due time. + return { + "schema_version": 1, "status": "attention", "updated_at": "2026-10-22T16:27:44+09:00", + "expected_close_session": "2026-10-21", "issues": [], + "jobs": {"close": { + "status": "completed", "data_status": "completed", "returncode": 0, + "result": {"expected_session": "2026-10-22", "last_completed_session": "2026-10-22", + "coverage": {"session": "2026-10-22", "status": "complete", "expected": 2, + "present": 2, "missing": 0, "baskets": [ + {"basket": name, "status": "present", "close_count": 1, + "latest_close_session": "2026-10-22"} + for name in ("hold", "pocket")]}}}, + "research": {"status": "attention"}}, + } + + +def test_early_close_remains_current_after_schedule_without_rewriting_saved_report(tmp_path, monkeypatch): + report = early_completed_report() + daily._save(report, tmp_path) + original = (tmp_path / "latest.json").read_bytes() + monkeypatch.setattr(daily, "_run_child", Mock(side_effect=AssertionError("read must not collect"))) + + for stamp in ("2026-10-22T16:29:00+09:00", "2026-10-22T16:31:00+09:00", "2026-10-23T08:30:00+09:00"): + result = daily.read_daily_observation(tmp_path, now=datetime.fromisoformat(stamp)) + assert result["status"] == "attention" # A separate research failure remains visible. + assert result["expected_close_session"] == "2026-10-22" + assert result["jobs"] == report["jobs"] + assert (tmp_path / "latest.json").read_bytes() == original + + next_close_due = datetime.fromisoformat("2026-10-23T16:31:00+09:00") + stale = daily.read_daily_observation(tmp_path, now=next_close_due) + assert stale["status"] == "stale" + assert stale["expected_close_session"] == "2026-10-22" + + +@pytest.mark.parametrize("path,value", [ + (("status",), "attention"), + (("data_status",), "partial"), + (("returncode",), 2), + (("result", "expected_session"), "2026-10-21"), + (("result", "last_completed_session"), "2026-10-21"), + (("result", "coverage", "status"), "partial"), + (("result", "coverage", "session"), "2026-10-21"), + (("result", "coverage", "expected"), 0), + (("result", "coverage", "present"), 1), + (("result", "coverage", "missing"), 1), + (("result", "coverage", "baskets"), []), + (("result", "coverage", "baskets", 1, "latest_close_session"), "2026-10-21"), + (("result", "coverage", "baskets", 1, "close_count"), 0), +]) +def test_partial_or_inconsistent_close_cannot_hide_a_missing_session(tmp_path, path, value): + report = early_completed_report() + target = report["jobs"]["close"] + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + daily._save(report, tmp_path) + now = datetime.fromisoformat("2026-10-22T16:31:00+09:00") + assert daily.read_daily_observation(tmp_path, now=now)["status"] == "stale" + + +def test_future_close_date_cannot_advance_observed_session(tmp_path): + report = json.loads(json.dumps(early_completed_report()).replace("2026-10-22", "2026-10-23")) + report["updated_at"] = "2026-10-22T16:27:44+09:00" + daily._save(report, tmp_path) + now = datetime.fromisoformat("2026-10-22T16:31:00+09:00") + assert daily.read_daily_observation(tmp_path, now=now)["status"] == "stale" + + +def test_runner_saves_completed_session_when_it_starts_before_schedule(monkeypatch, tmp_path): + setup(monkeypatch) + now = datetime.fromisoformat("2026-10-22T16:27:44+09:00") + monkeypatch.setattr(daily, "_now", lambda: now) + close = early_completed_report()["jobs"]["close"] + monkeypatch.setattr(daily, "_run_child", Mock(side_effect=[ + (0, {"status": "completed", **close["result"]}), (1, {"status": "attention"}), + ])) + result = daily.run_daily_observation(tmp_path) + assert result["expected_close_session"] == "2026-10-22" + assert result["status"] == "attention" + assert json.loads((tmp_path / "latest.json").read_text(encoding="utf-8")) == result + assert json.loads(next((tmp_path / "runs").glob("*.json")).read_text(encoding="utf-8")) == result + + def test_overlapping_observation_does_not_spawn(monkeypatch, tmp_path): from core.runtime_lock import process_runtime_lock run = Mock() From cb58e5d218c9efe3bf28cf69fd5b036a2f20e7b6 Mon Sep 17 00:00:00 2001 From: easygap <103491329+easygap@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:04:52 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A4=91=EB=B3=B5=20=ED=8C=90=EC=A0=95=EA=B3=BC=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=8B=9C=EA=B0=81=EC=9D=84=20=ED=95=9C?= =?UTF-8?q?=EA=B5=AD=20=EC=8B=9C=EA=B0=84=EC=9C=BC=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/cycle_observability.py | 8 ++++++-- docs/RESEARCH_OPERATIONS_20261013.md | 4 +++- tests/test_audit_cycle.py | 24 ++++++++++++++++++++++++ tests/test_basket_overlay_integration.py | 2 +- tests/test_order_executor_paper.py | 6 ++++-- 5 files changed, 38 insertions(+), 6 deletions(-) diff --git a/core/cycle_observability.py b/core/cycle_observability.py index deaee2d..3b9c214 100644 --- a/core/cycle_observability.py +++ b/core/cycle_observability.py @@ -220,10 +220,12 @@ def record_event_once_per_day( 반환: 새로 기록했으면 True, 중복이라 건너뛰었거나 기록 실패면 False. """ - from datetime import datetime + from datetime import datetime, timedelta from zoneinfo import ZoneInfo - now = now or datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None) + now = now or datetime.now(ZoneInfo("Asia/Seoul")) + if now.tzinfo is not None: + now = now.astimezone(ZoneInfo("Asia/Seoul")).replace(tzinfo=None) day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) try: from database.models import OperationEvent, get_session @@ -234,6 +236,7 @@ def record_event_once_per_day( OperationEvent.event_type == event_type, OperationEvent.mode == mode, OperationEvent.created_at >= day_start, + OperationEvent.created_at < day_start + timedelta(days=1), ) for col, val in ( (OperationEvent.strategy, strategy), @@ -261,6 +264,7 @@ def record_event_once_per_day( message=message, detail=dedupe_key, mode=mode, + created_at=now, )) session.commit() finally: diff --git a/docs/RESEARCH_OPERATIONS_20261013.md b/docs/RESEARCH_OPERATIONS_20261013.md index 23148cb..2093110 100644 --- a/docs/RESEARCH_OPERATIONS_20261013.md +++ b/docs/RESEARCH_OPERATIONS_20261013.md @@ -73,7 +73,9 @@ Python 관련 검사 108개(기존 수집·검증 82개, 누적 성과 26개), J 10월 23일 오전 실행에서 전일 종가 두 건과 같은 날짜 비교지수를 확인했다. 두 바스켓 모두 누적 종가 8개·수익 구간 7개가 유지되며 현재 버전은 종가 2개·수익 구간 1개다. 아직 장기 성과나 변경 효과를 판정할 표본은 부족하다. -관련 회귀 검사 87개를 통과했고, 마지막 조회 결과 보완 후 일일 관측 검사 27개를 다시 통과했다. 실제 보고서의 원본 보존, 운영 DB와 기존 관측 이벤트의 해시 일치, 기존 검증 묶음 유지도 확인했다. 최종 검증 자료는 `output/daily_observation_review_20261023/verification.json`에 보존한다. +**운영 이벤트의 시간대 불일치도 수정했다.** 전체 CI에서 UTC와 한국 날짜가 다른 시간대의 실패를 확인했다. `record_event_once_per_day`는 한국 날짜로 중복을 조회하면서 저장 시각은 호스트 기본값을 사용해, UTC 호스트에서 같은 이벤트를 반복 기록할 수 있었다. 운영 DB를 메모리로 복사한 사본에서 이를 재현하고 조회·저장에 같은 한국 시각을 사용하도록 바꿨다. 다른 날짜의 기록이 중복 판정에 섞이지 않도록 하루의 상한도 적용한다. 운영 DB나 과거 이벤트를 다시 쓰지는 않는다. 함께 실패한 당일 봉·일일 손실 기준 검사 두 건은 테스트 자료의 날짜를 본체와 같은 한국 시간 기준으로 맞췄다. + +관련 회귀 검사 87개, 마지막 조회 결과 보완 후 일일 관측 검사 27개, 시간대 수정 관련 검사 112개를 통과했다. UTC·KST·시간대 없는 입력의 중복 억제와 기록 시각도 검증했다. 실제 보고서의 원본 보존, 운영 DB와 기존 관측 이벤트의 해시 일치, 기존 검증 묶음 유지도 확인했다. 최종 검증 자료는 `output/daily_observation_review_20261023/verification.json`에 보존한다. 다음 항목은 이번에 구현한 기능이 아니라, 코드와 저장 자료로 확인한 후속 과제다. diff --git a/tests/test_audit_cycle.py b/tests/test_audit_cycle.py index 0eb678c..42bc732 100644 --- a/tests/test_audit_cycle.py +++ b/tests/test_audit_cycle.py @@ -306,6 +306,30 @@ def test_event_once_per_day_dedupes_same_cause(): dedupe_key="사유 A", **kw) is True +@pytest.mark.parametrize("stamp", ["2026-10-22T23:58:00+00:00", "2026-10-23T08:58:00+09:00", "2026-10-23T08:58:00"]) +def test_event_dedupe_uses_one_kst_clock_for_query_and_insert(monkeypatch, stamp): + from core.cycle_observability import record_event_once_per_day + from database.models import OperationEvent, get_session + + # UTC 호스트의 기본 저장 시각과 한국 날짜가 다른 상황을 고정해 재현한다. + monkeypatch.setattr(OperationEvent.created_at.default, "arg", lambda ctx: datetime(2026, 10, 22, 23, 58)) + kw = dict(strategy=f"kst_dedupe:{stamp}", mode="paper", dedupe_key="same_cause", now=datetime.fromisoformat(stamp)) + assert record_event_once_per_day("ORDER_REJECTED", "검증용 이벤트", **kw) is True + assert record_event_once_per_day("ORDER_REJECTED", "검증용 이벤트", **kw) is False + session = get_session() + try: + saved = session.query(OperationEvent).filter_by(strategy=kw["strategy"]).one() + assert saved.created_at == datetime(2026, 10, 23, 8, 58) + finally: + session.close() + + # 다른 날의 기록은 중복이 아니다. 미래 행도 과거 날짜의 기록을 막지 않는다. + for day in (24, 22): + kw["now"] = datetime(2026, 10, day, 8, 58) + assert record_event_once_per_day("ORDER_REJECTED", "검증용 이벤트", **kw) is True + assert record_event_once_per_day("ORDER_REJECTED", "검증용 이벤트", **kw) is False + + # ------------------------------------------------------------------ 커버리지 게이트 def test_coverage_gate_uses_measured_days_not_reconstructed(): diff --git a/tests/test_basket_overlay_integration.py b/tests/test_basket_overlay_integration.py index 1685cd4..7b8aa32 100644 --- a/tests/test_basket_overlay_integration.py +++ b/tests/test_basket_overlay_integration.py @@ -152,7 +152,7 @@ def test_today_bar_is_excluded(self): from datetime import datetime closes = [100.0] * 200 + [50.0] rb = _make(_basket(self.overlays), closes) - today = datetime.now().strftime("%Y-%m-%d") + today = datetime.now(_KST).strftime("%Y-%m-%d") dates = list(pd.bdate_range(end=_previous_session(), periods=200)) + [pd.Timestamp(today)] rb.data_collector.fetch_korean_stock = lambda symbol, start_date=None, end_date=None: pd.DataFrame({"date": dates, "close": closes}) got = rb._fetch_index_closes("KS200", 200) diff --git a/tests/test_order_executor_paper.py b/tests/test_order_executor_paper.py index c5462b8..e0949dc 100644 --- a/tests/test_order_executor_paper.py +++ b/tests/test_order_executor_paper.py @@ -1640,15 +1640,17 @@ def test_daily_loss_baseline_skips_when_only_today_snapshot(fresh_db): 되면서 일일 손실 한도 가드가 조용히 무력화됐다. """ from datetime import datetime, timedelta + from zoneinfo import ZoneInfo from core.order_executor import OrderExecutor from database.models import get_session, PortfolioSnapshot executor = OrderExecutor(account_key="daily_loss_baseline_test") + today = datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None) # 오늘 스냅샷만 1개 — 기준값은 None 이어야 한다(가드는 일일 손실 점검만 skip). session = get_session() session.add(PortfolioSnapshot( - account_key="daily_loss_baseline_test", date=datetime.now(), + account_key="daily_loss_baseline_test", date=today, total_value=9_000_000, cash=9_000_000, invested=0, daily_return=0.0, cumulative_return=-10.0, mdd=-10.0, position_count=0, )) @@ -1659,7 +1661,7 @@ def test_daily_loss_baseline_skips_when_only_today_snapshot(fresh_db): # 전일 스냅샷을 추가하면 그 값(전일 평가금액)을 기준으로 쓴다. session = get_session() session.add(PortfolioSnapshot( - account_key="daily_loss_baseline_test", date=datetime.now() - timedelta(days=1), + account_key="daily_loss_baseline_test", date=today - timedelta(days=1), total_value=10_000_000, cash=10_000_000, invested=0, daily_return=0.0, cumulative_return=0.0, mdd=0.0, position_count=0, ))