auto: 일일 백업 2026-09-23 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-09-23 02:00:05 +09:00
parent 408a3e0d4a
commit 0157b24a55
122 changed files with 1751 additions and 1238 deletions
@@ -1,120 +0,0 @@
#!/usr/bin/env python3
"""KRX 애프터마켓(16:00~20:00, 2026-09-14 시행) 실측 probe — **조회 전용, 일회성**.
세션 모델을 고치기 전에 확인해야 할 4가지를 시각대별로 스냅샷으로 남긴다.
분석이 끝나면 이 스크립트와 plist(ai.openclaw.stock.aftermarket-probe)를 지운다.
확인 대상:
1. 애프터마켓 체결가가 KRX / NXT / SOR(AL) 중 어디로 오는가 (ka10095 3거래소 동시 조회)
2. 애프터마켓 가격제한폭이 정규장과 같은가 (`upl_pric`/`lst_pric` 시각대별 비교)
3. KRX 정규장 미체결이 15:35 증거금 해지 후 ka10075 에서 빠지는가
4. 애프터마켓에 호가가 뜨는가 (ka10004 bare=KRX / _AL)
⚠️ 주문 경로를 일절 건드리지 않는다 — orders 패키지를 import 하지 않고 kiwoom_client 만 쓴다.
⚠️ 응답을 **원시 그대로** 저장한다. 목적이 '새 필드가 생겼는지' 확인이라 정규화하면 안 된다.
⚠️ 한 항목이 실패해도 나머지는 계속 수집한다 (에러도 같이 기록).
결과: state/aftermarket_probe.jsonl — 1회차 = 1줄
사용: python3 aftermarket_probe.py [--tag <라벨>]
"""
from __future__ import annotations
import json
import sys
import traceback
from datetime import datetime, timedelta, timezone
from pathlib import Path
KST = timezone(timedelta(hours=9))
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
sys.path.insert(0, str(WORKSPACE / 'scripts'))
import kiwoom_client as kc # noqa: E402
OUT = WORKSPACE / 'state' / 'aftermarket_probe.jsonl'
# NXT 미상장 일반주 = 애프터마켓에서 새로 거래 가능해지는 쪽 (관심의 핵심)
# NXT 상장 = 기존에도 15:30 이후 거래되던 쪽 (대조)
# ETF = 애프터마켓 거래불가로 공지된 쪽 (대조)
TARGETS = [
('005935', '삼성전자우', 'nxt_미상장'),
('064400', 'LG씨엔에스', 'nxt_미상장'),
('381620', '제닉스로보틱스', 'nxt_미상장'),
('025980', '아난티', 'nxt_미상장'),
('005930', '삼성전자', 'nxt_상장'),
('000660', 'SK하이닉스', 'nxt_상장'),
('102110', 'TIGER 200', 'etf'),
]
BOOK_CODES = ['005935', '005930', '102110'] # 호가는 유형별 대표 1종목씩만
def _safe(fn, *a, **kw):
"""수집 실패를 예외로 흘리지 않는다 — 한 항목 실패가 회차 전체를 날리면 안 된다."""
try:
return {'ok': True, 'data': fn(*a, **kw)}
except Exception as e:
return {'ok': False, 'error': f'{e!r}', 'trace': traceback.format_exc(limit=3)}
def _raw_ka10095(codes: list[str], suffix: str) -> dict:
"""ka10095 원시 응답. get_watchlist_quotes 는 필드를 추려버려서 직접 부른다."""
label = kc._default_account_label()
param = '|'.join(f'{c}{suffix}' for c in codes)
return kc._call(label, kc.TR_WATCHLIST_INFO, {'stk_cd': param},
endpoint=kc.ENDPOINT_STKINFO)
def _raw_ka10004(code: str, suffix: str) -> dict:
label = kc._default_account_label()
url = kc.base_url() + kc.ENDPOINT_MRKCOND
resp, _ = kc._http_post_full(url, {'stk_cd': f'{code}{suffix}'},
kc.auth_headers(label, tr_id='ka10004'))
return resp
def collect(tag: str) -> dict:
now = datetime.now(KST)
codes = [c for c, _, _ in TARGETS]
rec: dict = {
'ts': now.isoformat(),
'tag': tag,
'targets': [{'code': c, 'name': n, 'kind': k} for c, n, k in TARGETS],
'quotes': {},
'books': {},
'open_orders': None,
}
# 1·2번 — 같은 종목을 세 거래소로 동시에 물어 값이 갈리는지 본다
for ex_name, suffix in (('KRX', ''), ('NXT', '_NX'), ('SOR', '_AL')):
rec['quotes'][ex_name] = _safe(_raw_ka10095, codes, suffix)
# 4번 — 애프터마켓에 호가가 실리는지
for code in BOOK_CODES:
for ex_name, suffix in (('KRX', ''), ('SOR', '_AL')):
rec['books'][f'{code}:{ex_name}'] = _safe(_raw_ka10004, code, suffix)
# 3번 — KRX 미체결 생존 (증거금 해지 15:35 / NXT 20:05)
rec['open_orders'] = _safe(kc.get_open_orders_all)
return rec
def main() -> int:
tag = 'manual'
if '--tag' in sys.argv:
tag = sys.argv[sys.argv.index('--tag') + 1]
rec = collect(tag)
OUT.parent.mkdir(parents=True, exist_ok=True)
with OUT.open('a', encoding='utf-8') as f:
f.write(json.dumps(rec, ensure_ascii=False) + '\n')
ok_q = sum(1 for v in rec['quotes'].values() if v['ok'])
ok_b = sum(1 for v in rec['books'].values() if v['ok'])
oo = rec['open_orders']
n_oo = len(oo['data']) if oo['ok'] else -1
print(f"[{rec['ts']}] tag={tag} 시세 {ok_q}/3 · 호가 {ok_b}/{len(rec['books'])} · 미체결 {n_oo}건 → {OUT}")
return 0
if __name__ == '__main__':
sys.exit(main())
+11 -2
View File
@@ -1295,7 +1295,7 @@ def _build_owner_data(positions_by_acc: dict, balances: dict, journal_by_label:
nxt_inactive_set = set((prev_snap_all or {}).get('_nxt_inactive') or [])
for r in consolidated:
r['pred_close_krx'] = r.get('pred_close', 0)
if r.get('code') in nxt_inactive_set:
if r.get('code') in nxt_inactive_set or not _is_nxt_traded(r.get('code') or ''):
r['_nxt_inactive'] = True
# 개별 종목의 day_change/pred_close를 어제 NXT 종가 baseline으로 재계산.
# kt00018 NXT 응답의 pred_close_pric가 KRX 종가로 채워져 raw day_change가 NXT-vs-KRX 갭으로
@@ -1817,6 +1817,15 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
'price': qa.get('price', 0), 'change_pct': qa.get('change_pct', 0.0),
'change': qa.get('change', 0), 'volume': qa.get('volume', 0),
}
# NXT 미거래 종목은 통합(AL)과 KRX가 같은 시장값이어야 한다. 전날 NXT
# 스냅샷이 stale 하더라도 KRX 배치시세의 전일종가로 화면 기준을 즉시 교정한다.
if r.get('_nxt_inactive') and qk_valid:
krx_prev = (qk.get('price', 0) or 0) - (qk.get('change', 0) or 0)
if krx_prev > 0:
r['pred_close_snap'] = krx_prev
r['pred_close'] = krx_prev
r['day_change'] = qk.get('change', 0) or 0
r['day_change_pct'] = qk.get('change_pct', 0.0) or 0.0
# 단일 캔들 primary — 4값(open/high/low/price) 모두 있는 응답만 후보. 거래 없는 시간대 0값 응답 배제.
# 통합(AL) 우선 — 시간대에 따라 캔들이 뒤바뀌지 않고 하루 한 개로 유지된다.
# NXT 미거래 종목은 AL == KRX 라 결과가 같다. AL 이 불완전할 때만 KRX 로 폴백.
@@ -3052,7 +3061,7 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
if pc > 0:
pred_close_krx = pc
# NXT 미상장 종목은 baseline 무효화 → NXT 컬럼 `-` placeholder.
if c.get('_nxt_inactive'):
if c.get('_nxt_inactive') or not _is_nxt_traded(c.get('code') or ''):
pred_close_nxt = 0
else:
nxt_ref = c.get('_pred_close_nxt')
@@ -13,7 +13,7 @@ import time
import unittest
from unittest import mock
from orders import fill_watcher
from orders import fill_watcher, ledger
from orders.fill_watcher import Tracked
@@ -39,6 +39,10 @@ class FillWatcherPollTests(unittest.TestCase):
"""_FillWatcher._poll_once 직접 호출로 격리 (시간 의존 X)."""
def setUp(self):
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
mock.patch.object(ledger, 'append', return_value=None).start()
self.addCleanup(mock.patch.stopall)
fill_watcher._reset_for_test()
self.sent = []
self.fetch = mock.MagicMock(return_value=[])
@@ -130,6 +134,10 @@ class CancelWatcherTests(unittest.TestCase):
"""cancel kind 회귀 — ka10075 폴링으로 원주문이 사라지면 확정."""
def setUp(self):
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
mock.patch.object(ledger, 'append', return_value=None).start()
self.addCleanup(mock.patch.stopall)
fill_watcher._reset_for_test()
self.sent = []
self.fetch_exec = mock.MagicMock(return_value=[])
@@ -243,6 +251,10 @@ class FillWatcherQueueIOTests(unittest.TestCase):
"""큐 파일 read/append/persist."""
def setUp(self):
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
mock.patch.object(ledger, 'append', return_value=None).start()
self.addCleanup(mock.patch.stopall)
fill_watcher._reset_for_test()
self.addCleanup(fill_watcher._reset_for_test)
@@ -303,6 +315,10 @@ class FillWatcherWatchEntryTests(unittest.TestCase):
"""watch() = 큐 append + 데몬 ensure_running. subprocess.Popen mock."""
def setUp(self):
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
mock.patch.object(ledger, 'append', return_value=None).start()
self.addCleanup(mock.patch.stopall)
fill_watcher._reset_for_test()
self.addCleanup(fill_watcher._reset_for_test)
# subprocess.Popen mock — 실제 데몬 fork 막음
@@ -384,6 +400,10 @@ class FillWatcherDaemonAliveTests(unittest.TestCase):
"""is_daemon_alive — PID 파일 stale 검출."""
def setUp(self):
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
mock.patch.object(ledger, 'append', return_value=None).start()
self.addCleanup(mock.patch.stopall)
fill_watcher._reset_for_test()
self.addCleanup(fill_watcher._reset_for_test)
@@ -412,6 +432,10 @@ class FillWatcherSyncFromQueueTests(unittest.TestCase):
"""sync_from_queue — 큐 → _tracked 양방향 동기화."""
def setUp(self):
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
mock.patch.object(ledger, 'append', return_value=None).start()
self.addCleanup(mock.patch.stopall)
fill_watcher._reset_for_test()
self.addCleanup(fill_watcher._reset_for_test)
@@ -37,6 +37,7 @@ from kiwoom_client import ( # noqa: E402
KST = ZoneInfo('Asia/Seoul')
STATE_DIR = WORKSPACE / 'state'
SNAPSHOT_FILE = STATE_DIR / 'portfolio_daily_snapshot.json'
STOCK_CODES_FILE = STATE_DIR / 'stock_codes.json'
RECIPIENT = 'mini.snowoyh@gmail.com'
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
TELEGRAM_ACCOUNT = 'stock' # 레이 봇
@@ -161,6 +162,61 @@ def apply_ka10001_corrections(rows: list[dict]) -> int:
return applied
def correct_nxt_inactive_rows(rows: list[dict]) -> int:
"""NXT 미거래 보유종목을 KRX 시세로 보정한다.
kt00018 NXT 잔고 조회는 NXT 미거래 종목에서 KRX 최신가 대신 이전 거래일의
stale 가격을 반환할 수 있다. 종목 메타의 ``nxt_enable=false`` 인 코드만 KRX
ka10095 배치시세로 교체해 리포트와 일일 스냅샷에 같은 오류가 저장되지 않게 한다.
"""
codes = {r.get('code') for r in rows if r.get('code')}
if not codes:
return 0
cache = load_json(STOCK_CODES_FILE, {})
stocks = cache.get('stocks', {}) if isinstance(cache, dict) else {}
inactive = {
info.get('code')
for info in stocks.values()
if isinstance(info, dict)
and info.get('code') in codes
and info.get('nxt_enable') is False
}
if not inactive:
return 0
try:
quotes = get_watchlist_quotes(sorted(inactive), exchange='KRX')
except Exception as e:
print(f'[nxt-inactive-krx] fetch failed: {e}', file=sys.stderr)
return 0
corrected = 0
for r in rows:
if r.get('code') not in inactive:
continue
q = quotes.get(r['code']) or {}
price = q.get('price', 0) or 0
if price <= 0:
continue
change = q.get('change', 0) or 0
qty = r.get('qty', 0) or 0
buy_amount = r.get('buy_amount', 0) or 0
eval_value = price * qty
profit = eval_value - buy_amount
r.update({
'price': price,
'pred_close': price - change,
'day_change': change,
'day_change_pct': q.get('change_pct', 0.0) or 0.0,
'eval_value': eval_value,
'profit': profit,
'profit_rate': (profit / buy_amount * 100) if buy_amount else 0.0,
})
corrected += 1
return corrected
def collect_rows() -> tuple[list[dict], dict, dict]:
"""계좌별 포지션 → 평탄화된 row 리스트 + 계좌 잔고 + 계좌별 당일매매일지.
@@ -192,6 +248,9 @@ def collect_rows() -> tuple[list[dict], dict, dict]:
'tdy_buyq': p['tdy_buyq'],
'tdy_sellq': p['tdy_sellq'],
})
corrected = correct_nxt_inactive_rows(rows)
if corrected:
print(f'[nxt-inactive-krx] {corrected}개 보유행 KRX 시세 보정', file=sys.stderr)
balances = {a['label']: get_balance(a['label']) for a in list_accounts()}
journal_by_label = get_trade_journal_all()
return rows, balances, journal_by_label
@@ -88,7 +88,13 @@ RT_URL = 'http://127.0.0.1:18790/api/realtime/quotes'
RT_TIMEOUT = 5
MARKET_OPEN = (9, 0)
MARKET_CLOSE = (15, 35) # 15:30 마감 + 최종 체결 버퍼
# 20:00 = NXT 애프터마켓 + KRX 애프터마켓(2026-09-14 시행) 마감.
# ⚠️ 마감 후 버퍼를 두지 않는다 — 데이터원인 realtime_hub 가 20:00 에 종료되므로
# (`_market_open()` → `behive_web._market_phase_state()['active']`) 그 뒤 사이클은
# 시세·VI 를 못 받고 헛돈다. plist 도 19:59 까지만 발화한다.
# ⚠️ 확대가 성립하는 전제는 hub 구독이 `_AL`(통합) 이라는 것 — KRX 애프터마켓 체결이
# 그 구독으로 들어온다. bare 코드면 15:30 이후 시세가 끊겨 감시가 무의미하다.
MARKET_CLOSE = (20, 0)
def load_json(path: Path, default):
@@ -0,0 +1,76 @@
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
import stock_portfolio_report as report
class NxtInactiveCorrectionTests(unittest.TestCase):
def test_corrects_only_nxt_inactive_rows_with_krx_quote(self):
rows = [
{
'code': '381620', 'qty': 10, 'price': 6290, 'pred_close': 6290,
'day_change': 0, 'day_change_pct': 0.0, 'buy_amount': 58000,
'eval_value': 62900, 'profit': 4900, 'profit_rate': 8.45,
},
{
'code': '005930', 'qty': 2, 'price': 281000, 'pred_close': 275000,
'day_change': 6000, 'day_change_pct': 2.18, 'buy_amount': 500000,
'eval_value': 562000, 'profit': 62000, 'profit_rate': 12.4,
},
]
cache = {
'stocks': {
'제닉스로보틱스': {'code': '381620', 'nxt_enable': False},
'삼성전자': {'code': '005930', 'nxt_enable': True},
}
}
with tempfile.TemporaryDirectory() as td:
cache_path = Path(td) / 'stock_codes.json'
cache_path.write_text(json.dumps(cache))
with patch.object(report, 'STOCK_CODES_FILE', cache_path), patch.object(
report,
'get_watchlist_quotes',
return_value={
'381620': {'price': 6190, 'change': 40, 'change_pct': 0.65},
},
) as quotes:
corrected = report.correct_nxt_inactive_rows(rows)
self.assertEqual(corrected, 1)
quotes.assert_called_once_with(['381620'], exchange='KRX')
self.assertEqual(rows[0]['price'], 6190)
self.assertEqual(rows[0]['pred_close'], 6150)
self.assertEqual(rows[0]['eval_value'], 61900)
self.assertEqual(rows[0]['profit'], 3900)
self.assertAlmostEqual(rows[0]['profit_rate'], 3900 / 58000 * 100)
self.assertEqual(rows[1]['price'], 281000)
def test_keeps_original_row_when_krx_quote_is_missing(self):
row = {
'code': '381620', 'qty': 10, 'price': 6290, 'pred_close': 6290,
'day_change': 0, 'day_change_pct': 0.0, 'buy_amount': 58000,
'eval_value': 62900, 'profit': 4900, 'profit_rate': 8.45,
}
cache = {'stocks': {'제닉스로보틱스': {'code': '381620', 'nxt_enable': False}}}
with tempfile.TemporaryDirectory() as td:
cache_path = Path(td) / 'stock_codes.json'
cache_path.write_text(json.dumps(cache))
with patch.object(report, 'STOCK_CODES_FILE', cache_path), patch.object(
report, 'get_watchlist_quotes', return_value={}
):
corrected = report.correct_nxt_inactive_rows([row])
self.assertEqual(corrected, 0)
self.assertEqual(row['price'], 6290)
self.assertEqual(row['eval_value'], 62900)
if __name__ == '__main__':
unittest.main()