Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+846
@@ -0,0 +1,846 @@
|
||||
#!/usr/bin/env python3
|
||||
"""@비하이브투자자문 신규 '종목분석' 영상 감지 + 자막 수집 + watchlist 저장 + 이메일/텔레그램 발송 + 조회.
|
||||
|
||||
Subcommands:
|
||||
fetch — 신규 매칭 영상 메타데이터 JSON 출력(자막 제외) + 자막은 fetch 캐시에만 저장
|
||||
transcript VIDEO_ID — fetch 캐시에서 특정 영상의 자막만 꺼내 출력 (토큰 절감용)
|
||||
save VIDEO_ID — stdin JSON 분석 데이터를 watchlist에 저장하고 seen 기록
|
||||
add STOCK ... — 수동 watchlist 추가 (--code, --buy, --target, --stop, --note)
|
||||
email VIDEO_IDS... — watchlist에서 해당 영상들을 읽어 단일 이메일 발송
|
||||
notify VIDEO_IDS... — 레이 텔레그램으로 "N개 보고서 제출 (종목명들)" 요약 발송
|
||||
list — watchlist 전체 종목 요약 테이블 출력
|
||||
show STOCK — 특정 종목의 상세 분석 내용 출력
|
||||
remove STOCK — watchlist에서 특정 종목 제거
|
||||
|
||||
현재가 조회는 키움 ka10001(`kiwoom_client.get_stock_quote`) 사용.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
FEED_USER_AGENT = (
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/122.0.0.0 Safari/537.36'
|
||||
)
|
||||
FEED_RETRY_ATTEMPTS = 4
|
||||
FEED_RETRY_BACKOFF_SEC = 3.0
|
||||
FEED_RETRY_STATUS = {408, 429, 500, 502, 503, 504}
|
||||
|
||||
CHANNEL_ID = 'UCHTRF5r154igU2gXjudUMzg'
|
||||
CHANNEL_NAME = '비하이브 투자자문'
|
||||
FEED_URL = f'https://www.youtube.com/feeds/videos.xml?channel_id={CHANNEL_ID}'
|
||||
SEARCH_URL = 'https://www.youtube.com/results?search_query={query}&sp=CAISAhAB'
|
||||
TITLE_FILTER = '종목분석'
|
||||
TRANSCRIPT_LANGS = ['ko', 'ko-KR', 'en']
|
||||
TRANSCRIPT_CHAR_LIMIT = 8000
|
||||
FETCH_LIMIT = 10
|
||||
SEEN_CAP = 200
|
||||
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
STATE_DIR = WORKSPACE / 'state'
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SEEN_STATE = STATE_DIR / 'behive_youtube_seen.json'
|
||||
FETCH_CACHE = STATE_DIR / 'behive_last_fetch.json'
|
||||
WATCHLIST = STATE_DIR / 'behive_watchlist.json'
|
||||
|
||||
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
|
||||
TELEGRAM_ACCOUNT = 'stock'
|
||||
EMAIL_RECIPIENT = 'mini.snowoyh@gmail.com'
|
||||
|
||||
HTML_STYLES = {
|
||||
'wrap': 'font-family:-apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",sans-serif;font-size:14px;color:#222;max-width:680px;line-height:1.55;',
|
||||
'greet': 'color:#444;margin-bottom:18px;',
|
||||
'card': 'border:1px solid #e5e5e5;border-radius:8px;padding:16px 18px;margin-bottom:18px;background:#fafafa;',
|
||||
'card_title': 'font-size:16px;font-weight:700;color:#111;margin-bottom:4px;',
|
||||
'card_meta': 'color:#666;font-size:12px;font-weight:400;margin-left:6px;',
|
||||
'price_table': 'width:100%;border-collapse:collapse;background:#fff;border:1px solid #eee;border-radius:6px;margin:10px 0 14px;',
|
||||
'price_label': 'color:#666;padding:6px 10px;width:80px;font-size:13px;border-bottom:1px solid #f0f0f0;',
|
||||
'price_value': 'color:#111;padding:6px 10px;font-size:13px;font-weight:500;border-bottom:1px solid #f0f0f0;',
|
||||
'section': 'font-size:13px;font-weight:600;color:#333;margin:10px 0 6px;border-left:3px solid #333;padding-left:8px;',
|
||||
'bullet_list': 'margin:4px 0 8px 0;padding-left:20px;color:#333;font-size:13px;',
|
||||
'bullet_item': 'margin:3px 0;line-height:1.5;',
|
||||
'footer': 'color:#888;font-size:12px;margin-top:10px;border-top:1px dashed #ddd;padding-top:8px;',
|
||||
'link': 'color:#0066cc;text-decoration:none;',
|
||||
'pos': 'color:#d24f4f;font-weight:600;',
|
||||
'neg': 'color:#1565c0;font-weight:600;',
|
||||
}
|
||||
|
||||
|
||||
def html_escape(s) -> str:
|
||||
return (str(s)
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
.replace("'", '''))
|
||||
|
||||
|
||||
def load_json(path: Path, default):
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except Exception:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path: Path, data):
|
||||
"""tmp + rename으로 원자적 저장. 부분 쓰기 방지."""
|
||||
tmp = path.with_suffix(path.suffix + '.tmp')
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
import fcntl as _fcntl
|
||||
from contextlib import contextmanager as _contextmanager
|
||||
|
||||
|
||||
@_contextmanager
|
||||
def watchlist_lock():
|
||||
"""프로세스간 watchlist read-modify-write 직렬화.
|
||||
2026-04-27 비하이브 cron이 7개 save를 병렬 호출해 8종목 유실 사고 후 도입.
|
||||
fcntl.flock(LOCK_EX)로 같은 머신 내 모든 프로세스가 큐잉됨.
|
||||
"""
|
||||
lock_path = WATCHLIST.with_suffix(WATCHLIST.suffix + '.lock')
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(lock_path, 'a')
|
||||
try:
|
||||
_fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
_fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
|
||||
def _is_recent_published_text(text: str) -> bool:
|
||||
if not text:
|
||||
return True
|
||||
compact = text.replace(' ', '')
|
||||
digits = ''.join(ch for ch in compact if ch.isdigit())
|
||||
value = int(digits) if digits else None
|
||||
if '분전' in compact or '시간전' in compact or '일전' in compact:
|
||||
return True
|
||||
if '주전' in compact:
|
||||
return value is not None and value <= 1
|
||||
return False
|
||||
|
||||
|
||||
def _search_results_fallback() -> list[dict]:
|
||||
query = urllib.parse.quote(f'{CHANNEL_NAME} {TITLE_FILTER}')
|
||||
url = SEARCH_URL.format(query=query)
|
||||
req = urllib.request.Request(url, headers={'User-Agent': FEED_USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
html = r.read().decode('utf-8', 'ignore')
|
||||
|
||||
marker = 'var ytInitialData = '
|
||||
start = html.find(marker)
|
||||
if start < 0:
|
||||
raise RuntimeError('ytInitialData not found in YouTube search HTML')
|
||||
start += len(marker)
|
||||
end = html.find(';</script>', start)
|
||||
if end < 0:
|
||||
raise RuntimeError('ytInitialData terminator not found in YouTube search HTML')
|
||||
data = json.loads(html[start:end])
|
||||
|
||||
renderers = []
|
||||
|
||||
def walk(node):
|
||||
if isinstance(node, dict):
|
||||
vr = node.get('videoRenderer')
|
||||
if vr:
|
||||
renderers.append(vr)
|
||||
for value in node.values():
|
||||
walk(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
walk(value)
|
||||
|
||||
walk(data)
|
||||
|
||||
entries = []
|
||||
seen_ids = set()
|
||||
for vr in renderers:
|
||||
owner = ''.join(run.get('text', '') for run in vr.get('ownerText', {}).get('runs', []))
|
||||
title = ''.join(run.get('text', '') for run in vr.get('title', {}).get('runs', []))
|
||||
vid = vr.get('videoId', '')
|
||||
published = vr.get('publishedTimeText', {}).get('simpleText', '')
|
||||
if owner != CHANNEL_NAME or TITLE_FILTER not in title or not vid or vid in seen_ids:
|
||||
continue
|
||||
if not _is_recent_published_text(published):
|
||||
continue
|
||||
seen_ids.add(vid)
|
||||
entries.append({
|
||||
'video_id': vid,
|
||||
'title': title.strip(),
|
||||
'published': published,
|
||||
'url': f'https://www.youtube.com/watch?v={vid}',
|
||||
})
|
||||
if len(entries) >= FETCH_LIMIT:
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def fetch_feed() -> list[dict]:
|
||||
req = urllib.request.Request(FEED_URL, headers={'User-Agent': FEED_USER_AGENT})
|
||||
xml_text = None
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(1, FEED_RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
xml_text = r.read().decode('utf-8', 'ignore')
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code == 404:
|
||||
return _search_results_fallback()
|
||||
if e.code not in FEED_RETRY_STATUS or attempt == FEED_RETRY_ATTEMPTS:
|
||||
raise
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
last_err = e
|
||||
if attempt == FEED_RETRY_ATTEMPTS:
|
||||
raise
|
||||
time.sleep(FEED_RETRY_BACKOFF_SEC * attempt)
|
||||
if xml_text is None:
|
||||
raise last_err or RuntimeError('feed fetch failed without exception')
|
||||
root = ET.fromstring(xml_text)
|
||||
ns = {'a': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'}
|
||||
entries = []
|
||||
for entry in root.findall('a:entry', ns):
|
||||
vid = entry.findtext('yt:videoId', default='', namespaces=ns)
|
||||
title = entry.findtext('a:title', default='', namespaces=ns)
|
||||
published = entry.findtext('a:published', default='', namespaces=ns)
|
||||
link = ''
|
||||
for l in entry.findall('a:link', ns):
|
||||
if l.attrib.get('rel') == 'alternate':
|
||||
link = l.attrib.get('href', '')
|
||||
if vid:
|
||||
entries.append({
|
||||
'video_id': vid,
|
||||
'title': title.strip(),
|
||||
'published': published,
|
||||
'url': link or f'https://www.youtube.com/watch?v={vid}',
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def fetch_transcript(video_id: str) -> tuple[str, str]:
|
||||
try:
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
except Exception as e:
|
||||
return '', f'error: youtube_transcript_api import 실패 ({e})'
|
||||
try:
|
||||
api = YouTubeTranscriptApi()
|
||||
fetched = api.fetch(video_id, languages=TRANSCRIPT_LANGS)
|
||||
parts = []
|
||||
for snippet in fetched:
|
||||
text = getattr(snippet, 'text', None)
|
||||
if text is None and isinstance(snippet, dict):
|
||||
text = snippet.get('text', '')
|
||||
if text:
|
||||
parts.append(text.strip())
|
||||
full = ' '.join(parts).strip()
|
||||
if not full:
|
||||
return '', 'unavailable'
|
||||
if len(full) > TRANSCRIPT_CHAR_LIMIT:
|
||||
full = full[:TRANSCRIPT_CHAR_LIMIT] + '... [자막 일부 생략]'
|
||||
return full, 'ok'
|
||||
except Exception as e:
|
||||
return '', f'unavailable: {type(e).__name__}'
|
||||
|
||||
|
||||
def cmd_fetch() -> int:
|
||||
seen = set(load_json(SEEN_STATE, {'seen': []}).get('seen', []))
|
||||
entries = fetch_feed()
|
||||
matched = [
|
||||
e for e in entries
|
||||
if TITLE_FILTER in e['title'] and e['video_id'] not in seen
|
||||
]
|
||||
matched = list(reversed(matched))[:FETCH_LIMIT]
|
||||
for item in matched:
|
||||
text, status = fetch_transcript(item['video_id'])
|
||||
item['transcript'] = text
|
||||
item['transcript_status'] = status
|
||||
save_json(FETCH_CACHE, {'fetched_at': datetime.now(KST).isoformat(), 'items': matched})
|
||||
lean = [
|
||||
{k: v for k, v in item.items() if k != 'transcript'}
|
||||
for item in matched
|
||||
]
|
||||
print(json.dumps(lean, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_transcript(video_id: str) -> int:
|
||||
cache = load_json(FETCH_CACHE, {})
|
||||
for item in cache.get('items', []):
|
||||
if item.get('video_id') == video_id:
|
||||
status = item.get('transcript_status', 'unknown')
|
||||
text = item.get('transcript', '') or ''
|
||||
print(f'transcript_status: {status}')
|
||||
print('---')
|
||||
print(text)
|
||||
return 0
|
||||
print(f'video_id "{video_id}" not found in fetch cache', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def load_cached_video(video_id: str) -> dict:
|
||||
cache = load_json(FETCH_CACHE, {})
|
||||
for item in cache.get('items', []):
|
||||
if item.get('video_id') == video_id:
|
||||
return {
|
||||
'id': item.get('video_id'),
|
||||
'title': item.get('title'),
|
||||
'url': item.get('url'),
|
||||
'published': item.get('published'),
|
||||
}
|
||||
return {'id': video_id}
|
||||
|
||||
|
||||
def mark_seen(video_id: str):
|
||||
state = load_json(SEEN_STATE, {'seen': []})
|
||||
seen = state.get('seen', [])
|
||||
if video_id not in seen:
|
||||
seen.insert(0, video_id)
|
||||
state['seen'] = seen[:SEEN_CAP]
|
||||
save_json(SEEN_STATE, state)
|
||||
|
||||
|
||||
def _reclassify_stop_above_buy(entry: dict, stock: str) -> None:
|
||||
"""Long-only 가정: stop은 buy보다 낮아야 함. 어기면 notes로 이동시키고 stop을 null화."""
|
||||
buy = entry.get('buy') or {}
|
||||
stop = entry.get('stop') or {}
|
||||
if not isinstance(buy, dict) or not isinstance(stop, dict):
|
||||
return
|
||||
buy_primary = buy.get('primary')
|
||||
stop_value = stop.get('value')
|
||||
if not isinstance(buy_primary, (int, float)) or not isinstance(stop_value, (int, float)):
|
||||
return
|
||||
if stop_value < buy_primary:
|
||||
return
|
||||
stop_raw = stop.get('raw') or f'{stop_value}'
|
||||
notes = list(entry.get('notes') or [])
|
||||
notes.insert(0, f"(원문: '{stop_raw}' — 매입가 {buy_primary}원보다 높아 손절가 대신 지지선으로 재분류됨)")
|
||||
entry['notes'] = notes
|
||||
entry['stop'] = None
|
||||
print(
|
||||
f"[guard] {stock}: stop({stop_value}) >= buy.primary({buy_primary}) — 재분류 후 stop=null로 저장",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def cmd_save(video_id: str) -> int:
|
||||
raw = sys.stdin.read().strip()
|
||||
if not raw:
|
||||
print('empty stdin', file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
analysis = json.loads(raw)
|
||||
except Exception as e:
|
||||
print(f'invalid JSON: {e}', file=sys.stderr)
|
||||
return 2
|
||||
stock = analysis.get('stock', '').strip()
|
||||
if not stock:
|
||||
print('missing "stock" field', file=sys.stderr)
|
||||
return 2
|
||||
# 종목코드 자동 주입 (키움 기반 모니터링용). 실패 시 빈 문자열 — show/email은 여전히 종목명만으로 동작.
|
||||
code = (analysis.get('code') or '').strip()
|
||||
if not code:
|
||||
try:
|
||||
code = _get_kiwoom_client().resolve_stock_code(stock).get('code', '')
|
||||
except Exception as e:
|
||||
print(f'[warn] {stock} 종목코드 매핑 실패: {e}', file=sys.stderr)
|
||||
entry = {
|
||||
'stock': stock,
|
||||
'code': code,
|
||||
'target': analysis.get('target'),
|
||||
'buy': analysis.get('buy'),
|
||||
'stop': analysis.get('stop'),
|
||||
'upside_pct': analysis.get('upside_pct'),
|
||||
'summary': analysis.get('summary', []),
|
||||
'notes': analysis.get('notes', []),
|
||||
'video': analysis.get('video') or load_cached_video(video_id),
|
||||
'saved_at': datetime.now(KST).isoformat(),
|
||||
}
|
||||
_reclassify_stop_above_buy(entry, stock)
|
||||
with watchlist_lock():
|
||||
watchlist = load_json(WATCHLIST, {})
|
||||
existing = watchlist.get(stock)
|
||||
if isinstance(existing, dict) and existing.get('status') == 'pending_delete':
|
||||
mark_seen(video_id)
|
||||
print(f'skipped {stock} — pending_delete (video={video_id})', file=sys.stderr)
|
||||
return 0
|
||||
watchlist[stock] = entry
|
||||
save_json(WATCHLIST, watchlist)
|
||||
mark_seen(video_id)
|
||||
print(f'saved {stock} (video={video_id})')
|
||||
return 0
|
||||
|
||||
|
||||
def format_price_line(label: str, field) -> str:
|
||||
if not field or not isinstance(field, dict):
|
||||
return f'{label}: 언급 없음'
|
||||
raw = field.get('raw', '').strip()
|
||||
return f'{label}: {raw}' if raw else f'{label}: 언급 없음'
|
||||
|
||||
|
||||
def _get_kiwoom_client():
|
||||
"""Lazy import — 테스트 환경에서 kiwoom 자격증명 없을 때 다른 서브커맨드는 살아있도록."""
|
||||
sys.path.insert(0, str(WORKSPACE / 'scripts'))
|
||||
import kiwoom_client # type: ignore
|
||||
return kiwoom_client
|
||||
|
||||
|
||||
def fetch_current_price(stock_name_or_code: str, buy_price: float | int | None = None) -> str | None:
|
||||
"""키움 ka10001 현재가. buy_price 주어지면 매입가 대비 등락 표시. 실패 시 None."""
|
||||
if not stock_name_or_code:
|
||||
return None
|
||||
try:
|
||||
kc = _get_kiwoom_client()
|
||||
info = kc.resolve_stock_code(stock_name_or_code)
|
||||
q = kc.get_stock_quote(info['code'])
|
||||
price = q.get('price', 0)
|
||||
if not price:
|
||||
return None
|
||||
if isinstance(buy_price, (int, float)) and buy_price > 0:
|
||||
diff = price - buy_price
|
||||
pct = (diff / buy_price) * 100
|
||||
return f'{price:,}원 ({diff:+,.0f}원, {pct:+.2f}% vs 매입가)'
|
||||
return f'{price:,}원'
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _buy_primary(entry: dict) -> float | None:
|
||||
buy = entry.get('buy')
|
||||
if isinstance(buy, dict):
|
||||
v = buy.get('primary')
|
||||
if isinstance(v, (int, float)):
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def format_entry_block(entry: dict) -> str:
|
||||
stock = entry.get('stock', '')
|
||||
lines = [f'[종목분석] #{stock}', '━━━━━━━━━━']
|
||||
target_line = format_price_line('목표가', entry.get('target'))
|
||||
upside = entry.get('upside_pct')
|
||||
if upside is not None and '언급 없음' not in target_line:
|
||||
sign = '+' if upside >= 0 else ''
|
||||
target_line = f'{target_line} ({sign}{upside:g}%)'
|
||||
lines.append(target_line)
|
||||
lines.append(format_price_line('매입가', entry.get('buy')))
|
||||
lines.append(format_price_line('손절가', entry.get('stop')))
|
||||
current = fetch_current_price(entry.get('code') or stock, _buy_primary(entry))
|
||||
lines.append(f'현재가: {current}' if current else '현재가: 조회 불가')
|
||||
lines.append('')
|
||||
lines.append('주요내용')
|
||||
for b in entry.get('summary') or []:
|
||||
lines.append(f'- {b}')
|
||||
if entry.get('notes'):
|
||||
lines.append('')
|
||||
lines.append('기타')
|
||||
for b in entry['notes']:
|
||||
lines.append(f'- {b}')
|
||||
video = entry.get('video') or {}
|
||||
if video.get('url'):
|
||||
lines.append('')
|
||||
lines.append(f"출처: {video.get('title','')} {video['url']}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _format_current_html(current: str | None) -> str:
|
||||
"""fetch_current_price 반환값을 색상 강조 HTML로 변환."""
|
||||
if not current:
|
||||
return '<span style="color:#888;">조회 불가</span>'
|
||||
if 'vs 매입가' in current and '(' in current:
|
||||
try:
|
||||
head, tail = current.split('(', 1)
|
||||
inner, _, _ = tail.partition(')')
|
||||
color = HTML_STYLES['pos'] if inner.strip().startswith('+') else HTML_STYLES['neg']
|
||||
return f'{html_escape(head.strip())} <span style="{color}">({html_escape(inner)})</span>'
|
||||
except Exception:
|
||||
pass
|
||||
return html_escape(current)
|
||||
|
||||
|
||||
def format_entry_html_block(entry: dict) -> str:
|
||||
S = HTML_STYLES
|
||||
stock = entry.get('stock', '')
|
||||
current = fetch_current_price(entry.get('code') or stock, _buy_primary(entry))
|
||||
parts = [f'<div style="{S["card"]}">']
|
||||
parts.append(
|
||||
f'<div style="{S["card_title"]}">[종목분석] #{html_escape(stock)} '
|
||||
f'<span style="{S["card_meta"]}">현재가: {_format_current_html(current)}</span></div>'
|
||||
)
|
||||
|
||||
target_line = format_price_line('목표가', entry.get('target'))
|
||||
upside = entry.get('upside_pct')
|
||||
target_value = target_line.split(':', 1)[1].strip()
|
||||
if upside is not None and '언급 없음' not in target_line:
|
||||
sign = '+' if upside >= 0 else ''
|
||||
target_value = f'{html_escape(target_value)} <span style="{S["pos"] if upside >= 0 else S["neg"]}">({sign}{upside:g}%)</span>'
|
||||
else:
|
||||
target_value = html_escape(target_value)
|
||||
|
||||
rows = [
|
||||
('목표가', target_value),
|
||||
('매입가', html_escape(format_price_line('매입가', entry.get('buy')).split(':', 1)[1].strip())),
|
||||
('손절가', html_escape(format_price_line('손절가', entry.get('stop')).split(':', 1)[1].strip())),
|
||||
]
|
||||
parts.append(f'<table style="{S["price_table"]}">')
|
||||
for label, value in rows:
|
||||
parts.append(
|
||||
f'<tr><td style="{S["price_label"]}">{html_escape(label)}</td>'
|
||||
f'<td style="{S["price_value"]}">{value}</td></tr>'
|
||||
)
|
||||
parts.append('</table>')
|
||||
|
||||
summary = entry.get('summary') or []
|
||||
if summary:
|
||||
parts.append(f'<div style="{S["section"]}">주요내용</div>')
|
||||
parts.append(f'<ul style="{S["bullet_list"]}">')
|
||||
for b in summary:
|
||||
parts.append(f'<li style="{S["bullet_item"]}">{html_escape(b)}</li>')
|
||||
parts.append('</ul>')
|
||||
|
||||
notes = entry.get('notes') or []
|
||||
if notes:
|
||||
parts.append(f'<div style="{S["section"]}">기타</div>')
|
||||
parts.append(f'<ul style="{S["bullet_list"]}">')
|
||||
for b in notes:
|
||||
parts.append(f'<li style="{S["bullet_item"]}">{html_escape(b)}</li>')
|
||||
parts.append('</ul>')
|
||||
|
||||
video = entry.get('video') or {}
|
||||
if video.get('url'):
|
||||
title = html_escape(video.get('title', '영상'))
|
||||
parts.append(
|
||||
f'<div style="{S["footer"]}">출처: '
|
||||
f'<a href="{html_escape(video["url"])}" style="{S["link"]}" target="_blank">{title} ↗</a>'
|
||||
f'</div>'
|
||||
)
|
||||
parts.append('</div>')
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def build_email_html(entries: list[dict]) -> str:
|
||||
S = HTML_STYLES
|
||||
parts = [
|
||||
f'<div style="{S["wrap"]}">',
|
||||
f'<div style="{S["greet"]}">관리자님, 비하이브투자자문 신규 종목분석 {len(entries)}건을 전달드립니다.</div>',
|
||||
]
|
||||
for entry in entries:
|
||||
parts.append(format_entry_html_block(entry))
|
||||
parts.append('</div>')
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def _entries_for_video_ids(video_ids: list[str]) -> tuple[list[dict], list[str]]:
|
||||
watchlist = load_json(WATCHLIST, {})
|
||||
entries = []
|
||||
missing = []
|
||||
seen_keys = set()
|
||||
for vid in video_ids:
|
||||
matches = [
|
||||
e for e in watchlist.values()
|
||||
if (e.get('video') or {}).get('id') == vid
|
||||
]
|
||||
if not matches:
|
||||
missing.append(vid)
|
||||
continue
|
||||
for e in matches:
|
||||
key = (e.get('stock', ''), (e.get('video') or {}).get('id', ''))
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
entries.append(e)
|
||||
return entries, missing
|
||||
|
||||
|
||||
|
||||
def cmd_email(video_ids: list[str]) -> int:
|
||||
if not video_ids:
|
||||
print('no video_ids provided', file=sys.stderr)
|
||||
return 2
|
||||
entries, missing = _entries_for_video_ids(video_ids)
|
||||
if missing:
|
||||
print(f'watchlist missing video_ids: {missing}', file=sys.stderr)
|
||||
return 1
|
||||
if not entries:
|
||||
print('no entries to send', file=sys.stderr)
|
||||
return 1
|
||||
date_str = datetime.now(KST).strftime('%Y-%m-%d %H:%M')
|
||||
stock_names = ', '.join(e.get('stock', '') for e in entries)
|
||||
subject = f'[비하이브 종목분석] {len(entries)}개 보고서 — {date_str} ({stock_names})'
|
||||
html_body = build_email_html(entries)
|
||||
cmd = ['gog', 'gmail', 'send', '--to', EMAIL_RECIPIENT, '--subject', subject, '--body-html', html_body]
|
||||
p = subprocess.run(cmd, text=True, capture_output=True)
|
||||
if p.returncode != 0:
|
||||
print(p.stderr.strip() or p.stdout.strip(), file=sys.stderr)
|
||||
return 1
|
||||
print(f'emailed {len(entries)} entries to {EMAIL_RECIPIENT}')
|
||||
return 0
|
||||
|
||||
|
||||
def load_telegram_config() -> tuple[str, list[str]]:
|
||||
cfg = json.loads(CONFIG_PATH.read_text())
|
||||
acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
|
||||
token = acct['botToken']
|
||||
chat_ids = acct.get('allowFrom') or []
|
||||
return token, chat_ids
|
||||
|
||||
|
||||
def send_telegram(text: str) -> bool:
|
||||
token, chat_ids = load_telegram_config()
|
||||
if not chat_ids:
|
||||
print('no chat_ids configured', file=sys.stderr)
|
||||
return False
|
||||
url = f'https://api.telegram.org/bot{token}/sendMessage'
|
||||
ok = True
|
||||
for chat_id in chat_ids:
|
||||
payload = {
|
||||
'chat_id': chat_id,
|
||||
'text': text[:4000],
|
||||
'disable_web_page_preview': 'true',
|
||||
}
|
||||
data = urllib.parse.urlencode(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, method='POST')
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
if r.status != 200:
|
||||
print(f'telegram HTTP {r.status}', file=sys.stderr)
|
||||
ok = False
|
||||
except Exception as e:
|
||||
print(f'telegram send failed: {e}', file=sys.stderr)
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
def format_telegram_block(entry: dict) -> str:
|
||||
stock = entry.get('stock', '?')
|
||||
current = fetch_current_price(entry.get('code') or stock, _buy_primary(entry))
|
||||
current_line = f'현재가: {current}' if current else '현재가: 조회 불가'
|
||||
buy_line = format_price_line('매입가', entry.get('buy'))
|
||||
target_line = format_price_line('목표가', entry.get('target'))
|
||||
upside = entry.get('upside_pct')
|
||||
if upside is not None and '언급 없음' not in target_line:
|
||||
sign = '+' if upside >= 0 else ''
|
||||
target_line = f'{target_line} ({sign}{upside:g}%)'
|
||||
stop_line = format_price_line('손절가', entry.get('stop'))
|
||||
return f'#{stock}\n• {current_line}\n• {buy_line}\n• {target_line}\n• {stop_line}'
|
||||
|
||||
|
||||
def cmd_notify(video_ids: list[str]) -> int:
|
||||
if not video_ids:
|
||||
print('no video_ids provided', file=sys.stderr)
|
||||
return 2
|
||||
entries, _missing = _entries_for_video_ids(video_ids)
|
||||
if not entries:
|
||||
print('no matching entries in watchlist', file=sys.stderr)
|
||||
return 1
|
||||
header = f'비하이브 종목분석 {len(entries)}건 제출'
|
||||
blocks = [format_telegram_block(e) for e in entries]
|
||||
text = header + '\n\n' + '\n\n'.join(blocks)
|
||||
if send_telegram(text):
|
||||
print(f'notified {len(entries)} stocks')
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_list() -> int:
|
||||
w = load_json(WATCHLIST, {})
|
||||
if not w:
|
||||
print('watchlist 비어있음')
|
||||
return 0
|
||||
entries = sorted(w.values(), key=lambda e: e.get('saved_at', ''), reverse=True)
|
||||
print(f'총 {len(entries)}개 종목')
|
||||
print('-' * 90)
|
||||
print(f'{"종목명":<12} {"목표가":<28} {"상승률":<8} {"매입가":<24} {"저장일":<12}')
|
||||
print('-' * 90)
|
||||
for e in entries:
|
||||
stock = e.get('stock', '?')
|
||||
target_raw = (e.get('target') or {}).get('raw', '언급 없음')
|
||||
buy_raw = (e.get('buy') or {}).get('raw', '언급 없음')
|
||||
up = e.get('upside_pct')
|
||||
up_s = f'+{up}%' if up is not None else 'n/a'
|
||||
saved = (e.get('saved_at') or '')[:10]
|
||||
print(f'{stock:<12} {target_raw[:26]:<28} {up_s:<8} {buy_raw[:22]:<24} {saved:<12}')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show(stock: str) -> int:
|
||||
w = load_json(WATCHLIST, {})
|
||||
entry = w.get(stock)
|
||||
if not entry:
|
||||
matches = [k for k in w if stock in k]
|
||||
if len(matches) == 1:
|
||||
entry = w[matches[0]]
|
||||
elif len(matches) > 1:
|
||||
print(f'여러 종목 매칭: {", ".join(matches)}. 정확한 종목명 지정 필요.', file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
print(f'watchlist에 "{stock}" 없음. 현재 등록: {", ".join(w.keys()) or "(없음)"}', file=sys.stderr)
|
||||
return 1
|
||||
print(format_entry_block(entry))
|
||||
saved = entry.get('saved_at', '')
|
||||
if saved:
|
||||
print(f'\n(저장일시: {saved})')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_add(argv: list[str]) -> int:
|
||||
"""수동 워치리스트 추가.
|
||||
|
||||
usage: add <stock_name> [--code 005930] [--buy 13000] [--target 16000-17000]
|
||||
[--stop 12000] [--note "..."] [--note "..."]
|
||||
|
||||
--code 생략 시 키움 종목코드 캐시로 자동 매핑.
|
||||
--target 은 단일값 또는 'low-high' 형식.
|
||||
"""
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(prog='behive_youtube_digest.py add', add_help=True)
|
||||
p.add_argument('stock')
|
||||
p.add_argument('--code')
|
||||
p.add_argument('--buy', type=int, help='매입가 (단일 정수)')
|
||||
p.add_argument('--target', help='목표가 — "16000" 또는 "16000-17000"')
|
||||
p.add_argument('--stop', type=int, help='손절가')
|
||||
p.add_argument('--note', action='append', default=[])
|
||||
try:
|
||||
args = p.parse_args(argv)
|
||||
except SystemExit as e:
|
||||
return int(e.code or 2)
|
||||
|
||||
stock = args.stock.strip()
|
||||
# 종목코드 확보
|
||||
code = (args.code or '').strip()
|
||||
resolved_name = stock
|
||||
if not code:
|
||||
try:
|
||||
info = _get_kiwoom_client().resolve_stock_code(stock)
|
||||
code = info.get('code', '')
|
||||
# 캐시 공식 종목명과 다르면 교정 알림
|
||||
if info.get('name') and info['name'] != stock:
|
||||
print(f'[info] "{stock}" → 공식 종목명 "{info["name"]}"로 저장', file=sys.stderr)
|
||||
resolved_name = info['name']
|
||||
except Exception as e:
|
||||
print(f'종목코드 자동 매핑 실패: {e}. --code 6자리로 지정해주세요.', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
target = None
|
||||
if args.target:
|
||||
raw = args.target.strip()
|
||||
if '-' in raw:
|
||||
lo_s, hi_s = raw.split('-', 1)
|
||||
lo, hi = int(lo_s.replace(',', '').strip()), int(hi_s.replace(',', '').strip())
|
||||
target = {'raw': raw, 'low': lo, 'high': hi}
|
||||
else:
|
||||
v = int(raw.replace(',', '').strip())
|
||||
target = {'raw': raw, 'low': v, 'high': v}
|
||||
|
||||
buy = None
|
||||
if args.buy is not None:
|
||||
buy = {'raw': f'{args.buy:,}원', 'primary': args.buy, 'levels': [args.buy]}
|
||||
|
||||
stop = None
|
||||
if args.stop is not None:
|
||||
stop = {'raw': f'{args.stop:,}원', 'value': args.stop}
|
||||
|
||||
upside = None
|
||||
if target and buy and buy.get('primary'):
|
||||
upside = round((target['low'] / buy['primary'] - 1) * 100, 1)
|
||||
|
||||
entry = {
|
||||
'stock': resolved_name,
|
||||
'code': code,
|
||||
'target': target,
|
||||
'buy': buy,
|
||||
'stop': stop,
|
||||
'upside_pct': upside,
|
||||
'summary': [],
|
||||
'notes': list(args.note or []),
|
||||
'video': {'source': 'manual'},
|
||||
'saved_at': datetime.now(KST).isoformat(),
|
||||
}
|
||||
_reclassify_stop_above_buy(entry, resolved_name)
|
||||
with watchlist_lock():
|
||||
watchlist = load_json(WATCHLIST, {})
|
||||
watchlist[resolved_name] = entry
|
||||
save_json(WATCHLIST, watchlist)
|
||||
parts = [f'added {resolved_name} ({code})']
|
||||
if target: parts.append(f"target={target['raw']}")
|
||||
if buy: parts.append(f"buy={buy['raw']}")
|
||||
if stop: parts.append(f"stop={stop['raw']}")
|
||||
print(' · '.join(parts))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_remove(stock: str) -> int:
|
||||
with watchlist_lock():
|
||||
w = load_json(WATCHLIST, {})
|
||||
if stock not in w:
|
||||
print(f'watchlist에 "{stock}" 없음', file=sys.stderr)
|
||||
return 1
|
||||
w.pop(stock)
|
||||
save_json(WATCHLIST, w)
|
||||
print(f'removed {stock}')
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
cmd = sys.argv[1]
|
||||
if cmd == 'fetch':
|
||||
return cmd_fetch()
|
||||
if cmd == 'transcript':
|
||||
if len(sys.argv) < 3:
|
||||
print('usage: transcript VIDEO_ID', file=sys.stderr)
|
||||
return 2
|
||||
return cmd_transcript(sys.argv[2])
|
||||
if cmd == 'save':
|
||||
if len(sys.argv) < 3:
|
||||
print('usage: save VIDEO_ID (stdin = JSON)', file=sys.stderr)
|
||||
return 2
|
||||
return cmd_save(sys.argv[2])
|
||||
if cmd == 'add':
|
||||
if len(sys.argv) < 3:
|
||||
print('usage: add <stock_name> [--code 6자리] [--buy 13000] [--target 16000-17000] [--stop 12000] [--note "..."]', file=sys.stderr)
|
||||
return 2
|
||||
return cmd_add(sys.argv[2:])
|
||||
if cmd == 'email':
|
||||
return cmd_email(sys.argv[2:])
|
||||
if cmd == 'notify':
|
||||
return cmd_notify(sys.argv[2:])
|
||||
if cmd == 'list':
|
||||
return cmd_list()
|
||||
if cmd == 'show':
|
||||
if len(sys.argv) < 3:
|
||||
print('usage: show STOCK', file=sys.stderr)
|
||||
return 2
|
||||
return cmd_show(sys.argv[2])
|
||||
if cmd == 'remove':
|
||||
if len(sys.argv) < 3:
|
||||
print('usage: remove STOCK', file=sys.stderr)
|
||||
return 2
|
||||
return cmd_remove(sys.argv[2])
|
||||
print(f'unknown command: {cmd}', file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""stock.briefing 폴백·강제 재실행.
|
||||
|
||||
retry/final: 오늘 portfolio_daily_snapshot.json에 오늘 키가 이미 있으면 skip (idempotent).
|
||||
없으면 stock_portfolio_report.py send 재실행. final 모드에선 그래도 실패 시 레이 텔레그램 알림.
|
||||
|
||||
force: 스냅샷 존재 여부 무관하게 21:00 fresh fetch로 데이터 덮어쓰기. 20:10 데이터가
|
||||
부정확할 때 21:00 안정된 데이터로 갱신용. 스냅샷 있으면 run 모드(스냅샷만, 메일·텔레그램 X),
|
||||
없으면 send 모드로 폴백 + 실패 시 알림.
|
||||
|
||||
usage:
|
||||
briefing_fallback.py retry # 20:30 — 스냅샷 없으면 재실행, 실패해도 알림 없음
|
||||
briefing_fallback.py final # 스냅샷 없으면 재실행 + 그래도 없으면 알림 (전통적 폴백)
|
||||
briefing_fallback.py force # 21:00 — 무조건 fresh fetch (스냅샷 갱신만, 노이즈 없음)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo('Asia/Seoul')
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
SNAPSHOT = WORKSPACE / 'state' / 'portfolio_daily_snapshot.json'
|
||||
REPORT = WORKSPACE / 'scripts' / 'stock_portfolio_report.py'
|
||||
|
||||
sys.path.insert(0, str(WORKSPACE / 'scripts'))
|
||||
|
||||
|
||||
def today_snapshot_exists() -> bool:
|
||||
if not SNAPSHOT.exists():
|
||||
return False
|
||||
try:
|
||||
data = json.loads(SNAPSHOT.read_text())
|
||||
except Exception:
|
||||
return False
|
||||
today = datetime.now(KST).strftime('%Y-%m-%d')
|
||||
return today in data
|
||||
|
||||
|
||||
def run_briefing() -> int:
|
||||
return subprocess.call(['/usr/bin/python3', str(REPORT), 'send'])
|
||||
|
||||
|
||||
def alert(text: str) -> None:
|
||||
from send_balance_to_budget import send_telegram
|
||||
send_telegram(text)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ('retry', 'final', 'force'):
|
||||
print('usage: briefing_fallback.py {retry|final|force}', file=sys.stderr)
|
||||
return 2
|
||||
mode = sys.argv[1]
|
||||
now = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 휴장일/주말은 메인 stock.briefing이 self-skip하므로 폴백도 의미 없음.
|
||||
# 특히 final 모드의 거짓 "스냅샷 없음" 텔레그램 경보를 차단한다.
|
||||
try:
|
||||
from holiday_sync import is_market_day_today
|
||||
if not is_market_day_today():
|
||||
print(f'[{mode}] {now} KRX 휴장일/주말 — 폴백 스킵')
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f'[{mode}] holiday-check-fail: {e} — 평소대로 진행', file=sys.stderr)
|
||||
|
||||
if mode == 'force':
|
||||
# 21:00 무조건 재실행 — 20:10 데이터가 부정확할 때 fresh fetch로 덮어씀.
|
||||
# 스냅샷 있으면 run(스냅샷만), 없으면 send(메일·텔레그램·스냅샷 + 실패 시 알림).
|
||||
if today_snapshot_exists():
|
||||
print(f'[force] {now} 스냅샷 갱신 (run 모드, 메일·텔레그램 없음).')
|
||||
code = subprocess.call(['/usr/bin/python3', str(REPORT), 'run'])
|
||||
print(f'[force] {now} run exit={code}')
|
||||
return 0 if code == 0 else 1
|
||||
print(f'[force] {now} 스냅샷 없음 → send 재실행 (메일·텔레그램 포함).')
|
||||
code = run_briefing()
|
||||
print(f'[force] {now} send exit={code}')
|
||||
if today_snapshot_exists():
|
||||
return 0
|
||||
alert(
|
||||
f'⚠️ stock.briefing {now} 폴백 모두 실패\n'
|
||||
f'오늘({datetime.now(KST):%Y-%m-%d}) 스냅샷이 생성되지 않았습니다.\n'
|
||||
f'수동 실행: python3 {REPORT} send'
|
||||
)
|
||||
return 1
|
||||
|
||||
if today_snapshot_exists():
|
||||
print(f'[{mode}] {now} 오늘 스냅샷 있음, skip.')
|
||||
return 0
|
||||
|
||||
print(f'[{mode}] {now} 오늘 스냅샷 없음 → stock.briefing 재실행.')
|
||||
code = run_briefing()
|
||||
print(f'[{mode}] {now} stock.briefing exit={code}')
|
||||
|
||||
if today_snapshot_exists():
|
||||
print(f'[{mode}] {now} 재실행으로 스냅샷 생성됨.')
|
||||
return 0
|
||||
|
||||
if mode == 'final':
|
||||
alert(
|
||||
f'⚠️ stock.briefing {now} 폴백 모두 실패\n'
|
||||
f'오늘({datetime.now(KST):%Y-%m-%d}) 스냅샷이 생성되지 않았습니다.\n'
|
||||
f'수동 실행: python3 {REPORT} send'
|
||||
)
|
||||
return 1 if mode == 'final' else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""일봉 캐시 (state/daily_candles.sqlite).
|
||||
|
||||
자산웹 차트보기·분석 페이지에서 사용. 키움 ka10081 rate limit(분당 ~20콜)이 빡빡해
|
||||
한 번 받은 일봉은 sqlite 에 저장하고 어제까지의 봉은 재호출하지 않는다.
|
||||
|
||||
스키마:
|
||||
daily_candles(code, date, open, high, low, close, volume, value, turnover_rate)
|
||||
PRIMARY KEY (code, date)
|
||||
|
||||
정책:
|
||||
- 어제까지 봉만 저장. 오늘 봉은 장중 변동이라 캐시에 두지 않는다 (호출자가 ka10001 로 별도 결합).
|
||||
- upd_stkpc_tp=1 수정주가 기준
|
||||
- lazy fill 시 새로 받은 봉 중 캐시와 겹치는 날짜의 close 를 비교 → 다르면 권리락(분할/증자/감자) →
|
||||
250 개 전체 재구축. 현금배당은 차트 영향 없음 (배당락일에 한 봉 갭으로 자연스레 표시).
|
||||
|
||||
CLI (디버깅용):
|
||||
python3 daily_candles_cache.py <code> [count]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo('Asia/Seoul')
|
||||
WORKSPACE = Path(__file__).resolve().parent.parent
|
||||
DB_PATH = WORKSPACE / 'state' / 'daily_candles.sqlite'
|
||||
|
||||
CACHE_TARGET_COUNT = 250 # 첫 fill 시 받을 봉 수 (분석 페이지 1Y 와 동일)
|
||||
VERIFY_WINDOW = 5 # 권리락 감지: 캐시·신규 겹치는 마지막 N일 close 비교
|
||||
KIWOOM_FETCH_MAX = 600 # ka10081 한 콜 최대치 (안전 상한)
|
||||
MISMATCH_THRESHOLD = 2 # 겹친 봉 중 close 가 다른 게 N 개 이상이면 권리락으로 판정
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
c = sqlite3.connect(DB_PATH, isolation_level=None)
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS daily_candles (
|
||||
code TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
open INTEGER NOT NULL,
|
||||
high INTEGER NOT NULL,
|
||||
low INTEGER NOT NULL,
|
||||
close INTEGER NOT NULL,
|
||||
volume INTEGER NOT NULL,
|
||||
value INTEGER NOT NULL,
|
||||
turnover_rate REAL,
|
||||
PRIMARY KEY (code, date)
|
||||
)
|
||||
""")
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_code_date ON daily_candles(code, date DESC)")
|
||||
return c
|
||||
|
||||
|
||||
def _yesterday_kst() -> str:
|
||||
return (datetime.now(KST) - timedelta(days=1)).strftime('%Y%m%d')
|
||||
|
||||
|
||||
def _today_kst() -> str:
|
||||
return datetime.now(KST).strftime('%Y%m%d')
|
||||
|
||||
|
||||
def _row_to_dict(row: tuple) -> dict:
|
||||
return {
|
||||
'date': row[0],
|
||||
'open': row[1],
|
||||
'high': row[2],
|
||||
'low': row[3],
|
||||
'close': row[4],
|
||||
'volume': row[5],
|
||||
'value': row[6],
|
||||
'turnover_rate': row[7],
|
||||
}
|
||||
|
||||
|
||||
def _select_latest(code: str, count: int) -> list[dict]:
|
||||
"""캐시에서 최신 count 개 봉 (내림차순)."""
|
||||
with _conn() as c:
|
||||
rows = c.execute("""
|
||||
SELECT date, open, high, low, close, volume, value, turnover_rate
|
||||
FROM daily_candles WHERE code = ?
|
||||
ORDER BY date DESC LIMIT ?
|
||||
""", (code, count)).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def _replace_all(code: str, candles_desc: list[dict]) -> None:
|
||||
"""code 의 캐시 모두 삭제 후 신규 봉으로 교체. 권리락 발생 시 사용."""
|
||||
with _conn() as c:
|
||||
c.execute("BEGIN")
|
||||
c.execute("DELETE FROM daily_candles WHERE code = ?", (code,))
|
||||
c.executemany("""
|
||||
INSERT INTO daily_candles
|
||||
(code, date, open, high, low, close, volume, value, turnover_rate)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", [
|
||||
(code, x['date'], x['open'], x['high'], x['low'], x['close'],
|
||||
x['volume'], x['value'], x.get('turnover_rate'))
|
||||
for x in candles_desc
|
||||
])
|
||||
c.execute("COMMIT")
|
||||
|
||||
|
||||
def _upsert_many(code: str, candles_desc: list[dict]) -> None:
|
||||
"""INSERT OR REPLACE — 갭 보충용."""
|
||||
with _conn() as c:
|
||||
c.executemany("""
|
||||
INSERT OR REPLACE INTO daily_candles
|
||||
(code, date, open, high, low, close, volume, value, turnover_rate)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", [
|
||||
(code, x['date'], x['open'], x['high'], x['low'], x['close'],
|
||||
x['volume'], x['value'], x.get('turnover_rate'))
|
||||
for x in candles_desc
|
||||
])
|
||||
|
||||
|
||||
def _fetch_from_kiwoom(code: str, count: int) -> list[dict]:
|
||||
"""ka10081 호출 후 오늘 봉 제외한 어제까지 봉 반환 (최신순)."""
|
||||
import kiwoom_client as kc
|
||||
candles = kc.get_daily_candles(code, count=count)
|
||||
today = _today_kst()
|
||||
return [c for c in candles if c['date'] != today]
|
||||
|
||||
|
||||
def _date_gap_days(yyyymmdd_a: str, yyyymmdd_b: str) -> int:
|
||||
a = datetime.strptime(yyyymmdd_a, '%Y%m%d')
|
||||
b = datetime.strptime(yyyymmdd_b, '%Y%m%d')
|
||||
return (b - a).days
|
||||
|
||||
|
||||
def get_candles(code: str, count: int = CACHE_TARGET_COUNT) -> list[dict]:
|
||||
"""code 의 어제까지 일봉 최신 count 개를 시간 오름차순으로 반환.
|
||||
|
||||
동작:
|
||||
1) 캐시 조회. 비었으면 ka10081 으로 250 개 fetch 후 저장.
|
||||
2) 캐시의 최신 봉 date 가 어제 이전이면 갭 + 검증분만큼 받아옴.
|
||||
겹치는 봉의 close 가 MISMATCH_THRESHOLD 이상 다르면 권리락 → 250 개 재구축.
|
||||
그 외엔 새 봉만 upsert.
|
||||
3) 최신 count 개 슬라이스 후 reverse (render_svg_chart 가 오름차순 기대).
|
||||
"""
|
||||
cached_desc = _select_latest(code, max(count, CACHE_TARGET_COUNT))
|
||||
yesterday = _yesterday_kst()
|
||||
|
||||
if not cached_desc:
|
||||
fresh_desc = _fetch_from_kiwoom(code, CACHE_TARGET_COUNT)
|
||||
if fresh_desc:
|
||||
_replace_all(code, fresh_desc)
|
||||
cached_desc = fresh_desc
|
||||
else:
|
||||
latest_cached = cached_desc[0]['date']
|
||||
if latest_cached < yesterday:
|
||||
gap_estimate = _date_gap_days(latest_cached, yesterday) + VERIFY_WINDOW
|
||||
fetch_count = min(KIWOOM_FETCH_MAX, max(VERIFY_WINDOW + 2, gap_estimate * 2))
|
||||
fresh_desc = _fetch_from_kiwoom(code, fetch_count)
|
||||
|
||||
cached_by_date = {c['date']: c for c in cached_desc}
|
||||
overlap_mismatches = 0
|
||||
overlap_total = 0
|
||||
for fc in fresh_desc:
|
||||
if fc['date'] in cached_by_date:
|
||||
overlap_total += 1
|
||||
if fc['close'] != cached_by_date[fc['date']]['close']:
|
||||
overlap_mismatches += 1
|
||||
|
||||
if overlap_total >= 3 and overlap_mismatches >= MISMATCH_THRESHOLD:
|
||||
sys.stderr.write(
|
||||
f'[daily_candles_cache] {code}: 권리락 감지 '
|
||||
f'({overlap_mismatches}/{overlap_total} close 불일치) → 250개 재구축\n'
|
||||
)
|
||||
full_desc = _fetch_from_kiwoom(code, CACHE_TARGET_COUNT)
|
||||
if full_desc:
|
||||
_replace_all(code, full_desc)
|
||||
cached_desc = full_desc
|
||||
else:
|
||||
_upsert_many(code, fresh_desc)
|
||||
cached_desc = _select_latest(code, max(count, CACHE_TARGET_COUNT))
|
||||
|
||||
desc_slice = cached_desc[:count]
|
||||
return list(reversed(desc_slice))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print('usage: python3 daily_candles_cache.py <code> [count]')
|
||||
sys.exit(1)
|
||||
code = sys.argv[1]
|
||||
cnt = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
out = get_candles(code, count=cnt)
|
||||
for c in out:
|
||||
print(c)
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FnGuide 컴퍼니가이드 펀더멘털 조회 (조회 전용, 캐시 우선).
|
||||
|
||||
키움 REST에 없는 데이터를 보강한다:
|
||||
- 연간 재무 시계열 (매출액·영업이익·순이익·EPS·ROE·PER) → 매출액/EPS/영업이익 증가율 계산
|
||||
- 컨센서스 (목표주가·투자의견·추정EPS·추정PER·추정 참여기관수)
|
||||
|
||||
데이터원: https://comp.fnguide.com/SVO2/xml/Snapshot_all/{code}.xml (EUC-KR XML)
|
||||
FnGuide HTML은 이 XML을 JS로 렌더하므로 XML을 직접 받아 파싱한다.
|
||||
|
||||
⚠️ FnGuide 콘텐츠는 저작권 대상. 무단 대량수집·DB구축 금지.
|
||||
→ 종목별 파일 캐시(기본 12h) + 보유·관심 종목 on-demand 조회로만 사용한다.
|
||||
|
||||
호출부는 절대 예외에 의존하지 않는다 — 실패 시 None 반환(분석은 FnGuide 없이도 진행).
|
||||
|
||||
CLI (수동 확인용):
|
||||
python3 fnguide_client.py <code> # 펀더멘털 출력
|
||||
python3 fnguide_client.py <code> --fresh # 캐시 무시 재조회
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
CACHE_DIR = WORKSPACE / 'state' / 'fnguide_cache'
|
||||
|
||||
CACHE_TTL_SEC = 12 * 3600 # 펀더멘털은 분기 단위 갱신 → 12h 캐시면 충분
|
||||
SNAPSHOT_URL = 'https://comp.fnguide.com/SVO2/xml/Snapshot_all/{code6}.xml'
|
||||
_UA = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36')
|
||||
_TIMEOUT = 8
|
||||
|
||||
# financial_highlight field(컬럼)명 prefix → 표준 키
|
||||
_FIELD_MAP = {
|
||||
'매출액': 'sales',
|
||||
'영업이익(억원)': 'oper_profit',
|
||||
'당기순이익': 'net_profit',
|
||||
'부채비율': 'debt_ratio',
|
||||
'영업이익률': 'op_margin',
|
||||
'순이익률': 'net_margin',
|
||||
'ROA': 'roa',
|
||||
'ROE': 'roe',
|
||||
'EPS': 'eps',
|
||||
'BPS': 'bps',
|
||||
'PER': 'per',
|
||||
'PBR': 'pbr',
|
||||
'배당수익률': 'div_yield',
|
||||
}
|
||||
|
||||
|
||||
def _norm_code(code: str) -> str:
|
||||
"""'A005930' / '005930' / 005930 → '005930' (6자리 숫자)."""
|
||||
s = ''.join(ch for ch in str(code) if ch.isdigit())
|
||||
return s.zfill(6)[-6:] if s else ''
|
||||
|
||||
|
||||
def _num(s: str | None) -> float | None:
|
||||
if s is None:
|
||||
return None
|
||||
t = s.strip().replace(',', '')
|
||||
if not t or t in ('-', 'N/A', '_'):
|
||||
return None
|
||||
neg = t.startswith('(') and t.endswith(')') # (1,234) = 음수 표기 대비
|
||||
if neg:
|
||||
t = t[1:-1]
|
||||
try:
|
||||
v = float(t)
|
||||
return -v if neg else v
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _txt(e) -> str:
|
||||
return (e.text or '').strip() if e is not None else ''
|
||||
|
||||
|
||||
def _field_key(field_name: str) -> str | None:
|
||||
for prefix, key in _FIELD_MAP.items():
|
||||
if field_name.startswith(prefix):
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def _pct_growth(cur: float | None, prev: float | None) -> float | None:
|
||||
"""전년대비 증가율(%). 직전이 0/음수면 의미 없어 None."""
|
||||
if cur is None or prev is None or prev <= 0:
|
||||
return None
|
||||
return round((cur / prev - 1) * 100, 1)
|
||||
|
||||
|
||||
def _parse_annual(root) -> list[dict]:
|
||||
"""financial_highlight_annual → 실적 확정 연도만 시계열(오래된→최신).
|
||||
|
||||
(E) 추정 연도는 별도 highlight value가 비어있어 제외(컨센서스는 _parse_consensus).
|
||||
"""
|
||||
fha = root.find('.//financial_highlight_annual')
|
||||
if fha is None:
|
||||
return []
|
||||
fields = [_txt(f) for f in fha.findall('field')]
|
||||
keys = [_field_key(f) for f in fields]
|
||||
rows: list[dict] = []
|
||||
for rec in fha.findall('record'):
|
||||
period = _txt(rec.find('date'))
|
||||
if '(E)' in period: # 추정 연도 skip
|
||||
continue
|
||||
vals = [_txt(v) for v in rec.findall('value')]
|
||||
row: dict = {'period': period, 'fs_nm': _txt(rec.find('fs_nm'))}
|
||||
has_any = False
|
||||
for i, key in enumerate(keys):
|
||||
if key is None or i >= len(vals):
|
||||
continue
|
||||
n = _num(vals[i])
|
||||
row[key] = n
|
||||
if n is not None:
|
||||
has_any = True
|
||||
if has_any:
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _attach_growth(annual: list[dict]) -> None:
|
||||
"""각 연도 행에 전년대비 증가율(%) 추가 (in-place)."""
|
||||
for i, row in enumerate(annual):
|
||||
prev = annual[i - 1] if i > 0 else None
|
||||
for base, out in (('sales', 'sales_growth'),
|
||||
('oper_profit', 'oper_profit_growth'),
|
||||
('eps', 'eps_growth'),
|
||||
('net_profit', 'net_profit_growth')):
|
||||
row[out] = _pct_growth(row.get(base), prev.get(base) if prev else None)
|
||||
|
||||
|
||||
def _parse_consensus(root) -> dict | None:
|
||||
"""<consensus> 블록 → 목표주가·투자의견·추정 EPS/PER·참여기관수."""
|
||||
c = root.find('.//consensus')
|
||||
if c is None:
|
||||
return None
|
||||
out = {
|
||||
'date': _txt(c.find('date')) or None,
|
||||
'target_price': _num(_txt(c.find('target_price'))),
|
||||
'opinion': _num(_txt(c.find('opinion'))), # 1(매도)~5(매수) 스케일
|
||||
'eps': _num(_txt(c.find('eps'))), # 추정 EPS
|
||||
'per': _num(_txt(c.find('per'))), # 추정 PER
|
||||
'organ_count': _num(_txt(c.find('presume_organ_count'))),
|
||||
}
|
||||
# 전부 비면 의미 없음
|
||||
if not any(v is not None for k, v in out.items() if k != 'date'):
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
def _opinion_label(op: float | None) -> str | None:
|
||||
"""투자의견 1~5 → 한글 라벨."""
|
||||
if op is None:
|
||||
return None
|
||||
if op >= 4.5:
|
||||
return '적극매수'
|
||||
if op >= 3.5:
|
||||
return '매수'
|
||||
if op >= 2.5:
|
||||
return '중립'
|
||||
if op >= 1.5:
|
||||
return '매도'
|
||||
return '적극매도'
|
||||
|
||||
|
||||
def _fetch_xml(code6: str) -> str | None:
|
||||
url = SNAPSHOT_URL.format(code6=code6)
|
||||
req = urllib.request.Request(url, headers={
|
||||
'User-Agent': _UA,
|
||||
'Referer': 'https://comp.fnguide.com/',
|
||||
})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
||||
raw = resp.read()
|
||||
except Exception:
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
return raw.decode('euc-kr', 'replace')
|
||||
|
||||
|
||||
def _build(code6: str) -> dict | None:
|
||||
"""XML fetch → 파싱 → 표준 dict. 실패/데이터 없으면 None."""
|
||||
xml_text = _fetch_xml(code6)
|
||||
if not xml_text:
|
||||
return None
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError:
|
||||
return None
|
||||
|
||||
annual = _parse_annual(root)
|
||||
_attach_growth(annual)
|
||||
consensus = _parse_consensus(root)
|
||||
|
||||
if not annual and not consensus: # ETF·신규상장 등 펀더멘털 없음
|
||||
return None
|
||||
|
||||
latest = annual[-1] if annual else {}
|
||||
growth = {
|
||||
'sales_yoy': latest.get('sales_growth'),
|
||||
'oper_profit_yoy': latest.get('oper_profit_growth'),
|
||||
'eps_yoy': latest.get('eps_growth'),
|
||||
'net_profit_yoy': latest.get('net_profit_growth'),
|
||||
} if latest else {}
|
||||
|
||||
if consensus and consensus.get('opinion') is not None:
|
||||
consensus['opinion_label'] = _opinion_label(consensus['opinion'])
|
||||
|
||||
return {
|
||||
'code': code6,
|
||||
'fetched_at': time.strftime('%Y-%m-%dT%H:%M:%S+09:00', time.localtime()),
|
||||
'annual': annual, # 오래된→최신, 확정 실적만
|
||||
'latest_period': latest.get('period') if latest else None,
|
||||
'growth': growth, # 최신 연도 YoY 요약
|
||||
'consensus': consensus, # 목표주가·투자의견·추정치 (없으면 None)
|
||||
}
|
||||
|
||||
|
||||
def _cache_path(code6: str) -> Path:
|
||||
return CACHE_DIR / f'{code6}.json'
|
||||
|
||||
|
||||
def get_fundamentals(code: str, max_age_sec: int = CACHE_TTL_SEC,
|
||||
force: bool = False) -> dict | None:
|
||||
"""종목 펀더멘털 dict 반환 (캐시 우선). 조회 불가 시 None — 절대 raise 안 함.
|
||||
|
||||
반환 스키마는 _build() 참조. ETF·없는 종목·네트워크 실패는 None.
|
||||
"""
|
||||
code6 = _norm_code(code)
|
||||
if not code6:
|
||||
return None
|
||||
cp = _cache_path(code6)
|
||||
|
||||
if not force and cp.exists():
|
||||
try:
|
||||
if time.time() - cp.stat().st_mtime < max_age_sec:
|
||||
return json.loads(cp.read_text())
|
||||
except Exception:
|
||||
pass # 캐시 손상 → 재조회
|
||||
|
||||
data = _build(code6)
|
||||
if data is None:
|
||||
return None
|
||||
try:
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = cp.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False))
|
||||
tmp.replace(cp)
|
||||
except Exception:
|
||||
pass # 캐시 저장 실패해도 데이터는 반환
|
||||
return data
|
||||
|
||||
|
||||
def _cli() -> None:
|
||||
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
force = '--fresh' in sys.argv
|
||||
if not args:
|
||||
print('usage: python3 fnguide_client.py <code> [--fresh]')
|
||||
sys.exit(1)
|
||||
d = get_fundamentals(args[0], force=force)
|
||||
if d is None:
|
||||
print(f'{args[0]}: 펀더멘털 없음 (ETF/없는종목/조회실패)')
|
||||
sys.exit(0)
|
||||
print(json.dumps(d, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_cli()
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""한국 주식시장(KRX/KOSDAQ) 휴장일을 investing.com에서 받아 state/market_holidays.json에 저장.
|
||||
|
||||
- 올해 + 내년 데이터를 함께 수집해 12월 → 1월 갭 방지.
|
||||
- launchd `ai.openclaw.stock.holiday-sync`가 주 1회 호출 (LLM 미경유).
|
||||
- 실패 시 stderr만 남기고 기존 state 파일은 보존 (web view는 옛 데이터로 계속 동작).
|
||||
|
||||
CLI:
|
||||
python3 holiday_sync.py # 올해+내년 fetch 후 저장
|
||||
python3 holiday_sync.py --years 1 # 올해만
|
||||
python3 holiday_sync.py --show # 저장된 휴장일 출력 (network 호출 없음)
|
||||
|
||||
Source: https://kr.investing.com/holiday-calendar/service/getCalendarFilteredData
|
||||
필터: country[]=11(한국), 거래소 = "서울 증권 거래소" (KOSDAQ는 같은 날 동시 휴장이라 첫 매칭만)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
WORKSPACE = Path(__file__).resolve().parent.parent
|
||||
STATE_FILE = WORKSPACE / 'state' / 'market_holidays.json'
|
||||
HEALTH_FILE = WORKSPACE / 'state' / 'holiday_sync_health.json'
|
||||
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
|
||||
KST = ZoneInfo('Asia/Seoul')
|
||||
|
||||
URL = 'https://kr.investing.com/holiday-calendar/service/getCalendarFilteredData'
|
||||
NAGER_URL = 'https://date.nager.at/api/v3/PublicHolidays/{year}/KR'
|
||||
KOREA_COUNTRY_ID = 11
|
||||
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
|
||||
|
||||
ROW_RE = re.compile(
|
||||
r'<tr>\s*<td class="date[^"]*">([^<]*)</td>.*?<td>([^<]+)</td>\s*<td class="last">([^<]+)</td>',
|
||||
re.DOTALL,
|
||||
)
|
||||
DATE_RE = re.compile(r'(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일')
|
||||
|
||||
|
||||
def fetch_year(year: int) -> dict[str, str]:
|
||||
"""{date(YYYY-MM-DD) → 휴장명} for the given year. 빈 dict 반환 가능."""
|
||||
body = urllib.parse.urlencode([
|
||||
('country[]', str(KOREA_COUNTRY_ID)),
|
||||
('dateFrom', f'{year}-01-01'),
|
||||
('dateTo', f'{year}-12-31'),
|
||||
]).encode()
|
||||
req = urllib.request.Request(URL, data=body, method='POST', headers={
|
||||
'User-Agent': USER_AGENT,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Referer': 'https://kr.investing.com/holiday-calendar/',
|
||||
'Origin': 'https://kr.investing.com',
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9',
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
payload = json.loads(r.read().decode('utf-8'))
|
||||
html_str = payload.get('data', '')
|
||||
last_date: str | None = None
|
||||
out: dict[str, str] = {}
|
||||
for date_raw, exch, name in ROW_RE.findall(html_str):
|
||||
date_raw = date_raw.strip()
|
||||
exch = exch.strip()
|
||||
name = name.strip()
|
||||
if date_raw:
|
||||
m = DATE_RE.match(date_raw)
|
||||
if m:
|
||||
last_date = f'{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}'
|
||||
# 한국 KRX 본장 기준 — 서울/코스닥 공동 휴장이라 서울만 보면 충분.
|
||||
if last_date and last_date.startswith(str(year)) and '서울' in exch and last_date not in out:
|
||||
out[last_date] = name
|
||||
return out
|
||||
|
||||
|
||||
def fetch_nager(year: int) -> dict[str, str]:
|
||||
"""date.nager.at에서 한국 공휴일 fetch (평일만).
|
||||
investing.com이 임시공휴일(선거일·정부 지정 대체공휴일)을 누락하는 패턴 보강용.
|
||||
토·일은 KRX 자체 휴장이라 제외 — KRX 휴장 의미가 없음."""
|
||||
req = urllib.request.Request(NAGER_URL.format(year=year), headers={
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
data = json.loads(r.read().decode('utf-8'))
|
||||
out: dict[str, str] = {}
|
||||
for item in data:
|
||||
d = (item.get('date') or '').strip()
|
||||
name = (item.get('localName') or item.get('name') or '').strip()
|
||||
if not d or not name:
|
||||
continue
|
||||
try:
|
||||
y, m, dd = map(int, d.split('-'))
|
||||
if date(y, m, dd).weekday() >= 5:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
out[d] = name
|
||||
return out
|
||||
|
||||
|
||||
def is_holiday(date_str: str) -> bool:
|
||||
"""date_str(YYYY-MM-DD)이 KRX 휴장일이면 True. 데이터 파일 없거나 깨지면 False(안전 fallback).
|
||||
다른 스크립트가 self-skip 게이트로 import해서 사용한다."""
|
||||
if not STATE_FILE.exists():
|
||||
return False
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text())
|
||||
return date_str in (data.get('holidays') or {})
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_holiday_today() -> bool:
|
||||
return is_holiday(datetime.now(KST).strftime('%Y-%m-%d'))
|
||||
|
||||
|
||||
def is_market_day_today() -> bool:
|
||||
"""오늘이 KRX 정규장 영업일이면 True (평일 + 휴장일 아님).
|
||||
데이터 파일 누락 시 평일이면 True로 fall through — 검증 신호 가리지 않음."""
|
||||
now = datetime.now(KST)
|
||||
if now.weekday() >= 5: # 토(5)·일(6)
|
||||
return False
|
||||
return not is_holiday_today()
|
||||
|
||||
|
||||
def _send_telegram(text: str) -> bool:
|
||||
"""레이 텔레그램으로 자가 알림. 자격증명·발송 실패 시 stderr만 남기고 False."""
|
||||
try:
|
||||
cfg = json.loads(CONFIG_PATH.read_text())
|
||||
acct = cfg['channels']['telegram']['accounts']['stock']
|
||||
token = acct['botToken']
|
||||
chat_ids = acct.get('allowFrom') or []
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'telegram cfg load failed: {e}\n')
|
||||
return False
|
||||
if not chat_ids:
|
||||
return False
|
||||
url = f'https://api.telegram.org/bot{token}/sendMessage'
|
||||
ok = True
|
||||
for chat_id in chat_ids:
|
||||
body = urllib.parse.urlencode({
|
||||
'chat_id': chat_id, 'text': text[:4000], 'disable_web_page_preview': 'true',
|
||||
}).encode()
|
||||
try:
|
||||
req = urllib.request.Request(url, data=body, method='POST')
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
if r.status != 200:
|
||||
ok = False
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'telegram send failed: {e}\n')
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
def _load_health() -> dict:
|
||||
if not HEALTH_FILE.exists():
|
||||
return {'consecutive_both_failures': 0}
|
||||
try:
|
||||
return json.loads(HEALTH_FILE.read_text())
|
||||
except Exception:
|
||||
return {'consecutive_both_failures': 0}
|
||||
|
||||
|
||||
def _save_health(h: dict) -> None:
|
||||
HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
HEALTH_FILE.write_text(json.dumps(h, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def _update_health(both_failed: bool, error_lines: list[str]) -> None:
|
||||
"""sync 시도 결과로 health 상태 갱신 + 2회 연속 진입 시점에만 알림 1회."""
|
||||
now_iso = datetime.now(KST).isoformat()
|
||||
h = _load_health()
|
||||
prev = int(h.get('consecutive_both_failures') or 0)
|
||||
h['last_attempt_at'] = now_iso
|
||||
if both_failed:
|
||||
h['consecutive_both_failures'] = prev + 1
|
||||
h['last_failure_reason'] = '\n'.join(error_lines)[:500]
|
||||
if h['consecutive_both_failures'] == 2 and prev < 2:
|
||||
last_ok = h.get('last_success_at') or '기록 없음'
|
||||
text = (
|
||||
'🚨 holiday-sync 2회 연속 실패\n'
|
||||
'investing.com · nager.at 양쪽 모두 데이터 0건\n'
|
||||
f'마지막 성공: {last_ok}\n'
|
||||
f'오류:\n{h["last_failure_reason"]}\n'
|
||||
'state file은 옛 데이터 유지 — 휴장 가드는 정상 동작'
|
||||
)
|
||||
sent = _send_telegram(text)
|
||||
h['alert_sent_at'] = now_iso if sent else None
|
||||
else:
|
||||
h['consecutive_both_failures'] = 0
|
||||
h['last_success_at'] = now_iso
|
||||
h.pop('last_failure_reason', None)
|
||||
h.pop('alert_sent_at', None)
|
||||
_save_health(h)
|
||||
|
||||
|
||||
def cmd_sync(years_forward: int) -> int:
|
||||
this_year = datetime.now(KST).year
|
||||
years = list(range(this_year, this_year + max(1, years_forward)))
|
||||
investing_data: dict[str, str] = {}
|
||||
errors: list[str] = []
|
||||
for y in years:
|
||||
try:
|
||||
investing_data.update(fetch_year(y))
|
||||
except Exception as e:
|
||||
errors.append(f'investing fetch_year({y}): {e}')
|
||||
sys.stderr.write(errors[-1] + '\n')
|
||||
# nager는 healthcheck용으로 항상 시도 — 휴리스틱(investing 0건 연도 skip)은 사용 시점에만 적용.
|
||||
nager_data_raw: dict[str, str] = {}
|
||||
for y in years:
|
||||
try:
|
||||
nager_data_raw.update(fetch_nager(y))
|
||||
except Exception as e:
|
||||
errors.append(f'nager fetch_nager({y}): {e}')
|
||||
sys.stderr.write(errors[-1] + '\n')
|
||||
# 양쪽 다 0건이면 영구 변경/네트워크 차단 의심 — health 카운트 + 2회 연속 시 알림.
|
||||
both_failed = (not investing_data) and (not nager_data_raw)
|
||||
_update_health(both_failed, errors)
|
||||
if not investing_data:
|
||||
sys.stderr.write('no holidays parsed — investing.com 미반영. state file은 옛 데이터 유지.\n')
|
||||
return 3
|
||||
# KRX는 다음 연도 휴장 캘린더를 보통 11~12월에 공식 발표 → 그 전엔 investing.com도 비어 있음.
|
||||
# investing이 한 건이라도 잡은 연도에만 nager 보강 — 미발표 연도의 일반 공휴일이 KRX 휴장으로 잘못 등록되는 것 차단.
|
||||
investing_years = {int(d[:4]) for d in investing_data}
|
||||
nager_data = {d: n for d, n in nager_data_raw.items() if int(d[:4]) in investing_years}
|
||||
# investing.com(거래소 캘린더) 우선 — KRX 휴장 권위. nager는 누락분만 채움.
|
||||
holidays = {**nager_data, **investing_data}
|
||||
nager_extra = sorted(d for d in nager_data if d not in investing_data)
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
'fetched_at': datetime.now(KST).isoformat(),
|
||||
'sources': {'primary': URL, 'fallback': NAGER_URL.format(year='YYYY')},
|
||||
'years': years,
|
||||
'holidays': dict(sorted(holidays.items())),
|
||||
}
|
||||
tmp = STATE_FILE.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
tmp.replace(STATE_FILE)
|
||||
extra_msg = f' (nager 보강 {len(nager_extra)}일: {nager_extra})' if nager_extra else ''
|
||||
print(f'wrote {STATE_FILE} — {len(holidays)} holidays for {years}{extra_msg}', flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show() -> int:
|
||||
if not STATE_FILE.exists():
|
||||
sys.stderr.write(f'no state file: {STATE_FILE}\n')
|
||||
return 1
|
||||
data = json.loads(STATE_FILE.read_text())
|
||||
print(f'fetched_at: {data.get("fetched_at")}')
|
||||
print(f'years: {data.get("years")}')
|
||||
for d, n in (data.get('holidays') or {}).items():
|
||||
print(f' {d} {n}')
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(prog='holiday_sync')
|
||||
p.add_argument('--years', type=int, default=2, help='올해 + N년 (default 2: 올해와 내년)')
|
||||
p.add_argument('--show', action='store_true', help='저장된 휴장일 출력 후 종료 (네트워크 호출 없음)')
|
||||
args = p.parse_args(argv)
|
||||
if args.show:
|
||||
return cmd_show()
|
||||
return cmd_sync(args.years)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urljoin
|
||||
from datetime import date, datetime, timedelta
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo('Asia/Seoul')
|
||||
CALENDAR_ID = 'mini.snowoyh@gmail.com'
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
STATE_DIR = WORKSPACE / 'state'
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE = STATE_DIR / 'ipo_calendar_sync.json'
|
||||
SUBSCRIPTION_URL = 'https://www.38.co.kr/html/fund/index.htm?o=k'
|
||||
NAVER_IPO_URL = 'https://finance.naver.com/sise/ipo.naver'
|
||||
SOURCE_LABEL = '네이버 금융 IPO'
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventSpec:
|
||||
kind: str
|
||||
name: str
|
||||
start_date: date
|
||||
end_date: date # inclusive
|
||||
brokers: str
|
||||
source_url: str
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
prefix = '[공모청약]' if self.kind == 'subscription' else '[신규상장]'
|
||||
return f'{prefix} {self.name}'
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
label = '청약일' if self.kind == 'subscription' else '상장일'
|
||||
if self.start_date == self.end_date:
|
||||
date_str = self.start_date.isoformat()
|
||||
else:
|
||||
date_str = f'{self.start_date.isoformat()} ~ {self.end_date.isoformat()}'
|
||||
return (
|
||||
f'종목명: {self.name}\n'
|
||||
f'증권사: {self.brokers or "미확인"}\n'
|
||||
f'{label}: {date_str}\n'
|
||||
f'기준: {SOURCE_LABEL}\n'
|
||||
f'출처: {self.source_url}'
|
||||
)
|
||||
|
||||
@property
|
||||
def state_key(self) -> str:
|
||||
return f'{self.kind}|{self.name}'
|
||||
|
||||
|
||||
def run(cmd: list[str]) -> str:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
raise RuntimeError(p.stderr.strip() or p.stdout.strip() or 'command failed')
|
||||
return p.stdout
|
||||
|
||||
|
||||
def fetch(url: str, encoding: str = 'euc-kr') -> str:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
raw = r.read()
|
||||
if encoding == 'auto':
|
||||
# 한글 페이지는 utf-8과 cp949 둘 중 하나 — strict 디코드 성공하는 쪽 사용 (네이버가 메타태그로 거짓말하는 경우 대비)
|
||||
for enc in ('utf-8', 'cp949'):
|
||||
try:
|
||||
return raw.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode('utf-8', errors='ignore')
|
||||
return raw.decode(encoding, 'ignore')
|
||||
|
||||
|
||||
def clean_text(html_fragment: str) -> str:
|
||||
text = re.sub(r'<br\s*/?>', ' ', html_fragment, flags=re.I)
|
||||
text = re.sub(r'<[^>]+>', ' ', text)
|
||||
text = unescape(text)
|
||||
text = text.replace('\xa0', ' ')
|
||||
return re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
|
||||
def parse_html_rows(table_html: str) -> list[list[str]]:
|
||||
rows = []
|
||||
for row in re.findall(r'<tr[^>]*>(.*?)</tr>', table_html, re.S | re.I):
|
||||
cols = [clean_text(c) for c in re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', row, re.S | re.I)]
|
||||
if cols:
|
||||
rows.append(cols)
|
||||
return rows
|
||||
|
||||
|
||||
def parse_date_range(text: str) -> tuple[date, date] | None:
|
||||
text = text.strip()
|
||||
m = re.match(r'(\d{4})\.(\d{2})\.(\d{2})~(\d{2})\.(\d{2})$', text)
|
||||
if not m:
|
||||
return None
|
||||
y, m1, d1, m2, d2 = map(int, m.groups())
|
||||
return date(y, m1, d1), date(y, m2, d2)
|
||||
|
||||
|
||||
def parse_single_date(text: str) -> date | None:
|
||||
m = re.match(r'(\d{4})\.(\d{2})\.(\d{2})$', text.strip())
|
||||
if not m:
|
||||
return None
|
||||
y, mm, dd = map(int, m.groups())
|
||||
return date(y, mm, dd)
|
||||
|
||||
|
||||
def extract_next_data_json(html: str) -> dict:
|
||||
m = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', html, re.S)
|
||||
if not m:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(m.group(1))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def extract_naver_ipo_detail(detail_url: str) -> dict:
|
||||
html = fetch(detail_url, encoding='utf-8')
|
||||
data = extract_next_data_json(html)
|
||||
queries = data.get('props', {}).get('pageProps', {}).get('dehydratedState', {}).get('queries', [])
|
||||
for q in queries:
|
||||
result = q.get('state', {}).get('data', {}).get('result', {})
|
||||
ipo_info = result.get('ipoInfo')
|
||||
if ipo_info:
|
||||
return ipo_info
|
||||
return {}
|
||||
|
||||
|
||||
def extract_naver_ipo_entries() -> list[dict]:
|
||||
html = fetch(NAVER_IPO_URL, encoding='auto')
|
||||
entries = []
|
||||
seen = set()
|
||||
for m in re.finditer(r'<div class="item_area" id="([^"]+)">.*?<a href="(https://m\.stock\.naver\.com/ipo/[^"]+)"[^>]*>(.*?)</a>', html, re.S):
|
||||
code = m.group(1).strip()
|
||||
detail_url = m.group(2).strip()
|
||||
name = clean_text(m.group(3))
|
||||
if not code or code in seen or not name:
|
||||
continue
|
||||
seen.add(code)
|
||||
entries.append({'code': code, 'name': name, 'detail_url': detail_url})
|
||||
return entries
|
||||
|
||||
|
||||
def extract_brokers_from_naver_detail(detail_url: str) -> tuple[dict, str]:
|
||||
html = fetch(detail_url, encoding='utf-8')
|
||||
data = extract_next_data_json(html)
|
||||
queries = data.get('props', {}).get('pageProps', {}).get('dehydratedState', {}).get('queries', [])
|
||||
ipo_info = {}
|
||||
brokers = []
|
||||
for q in queries:
|
||||
result = q.get('state', {}).get('data', {}).get('result', {})
|
||||
if isinstance(result, dict) and result.get('ipoInfo'):
|
||||
ipo_info = result.get('ipoInfo') or {}
|
||||
join_managers = result.get('joinManagers') or []
|
||||
for item in join_managers:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = (item.get('orgNm') or '').strip()
|
||||
if name and name not in brokers:
|
||||
brokers.append(name)
|
||||
break
|
||||
return ipo_info, ','.join(brokers)
|
||||
|
||||
|
||||
def parse_subscription_events_from(cutoff: date) -> list[EventSpec]:
|
||||
events = []
|
||||
for entry in extract_naver_ipo_entries():
|
||||
info, brokers = extract_brokers_from_naver_detail(entry['detail_url'])
|
||||
start_raw = (info.get('poStartDate') or '').strip()
|
||||
end_raw = (info.get('poEndDate') or '').strip()
|
||||
if not start_raw or not end_raw or '미정' in start_raw or '미정' in end_raw:
|
||||
continue
|
||||
try:
|
||||
start_date = date.fromisoformat(start_raw)
|
||||
end_date = date.fromisoformat(end_raw)
|
||||
except ValueError:
|
||||
continue
|
||||
if end_date <= cutoff:
|
||||
continue
|
||||
events.append(EventSpec('subscription', info.get('compName') or entry['name'], start_date, end_date, brokers, entry['detail_url']))
|
||||
return events
|
||||
|
||||
|
||||
def parse_listed_events_from(cutoff: date) -> list[EventSpec]:
|
||||
events = []
|
||||
for entry in extract_naver_ipo_entries():
|
||||
info, brokers = extract_brokers_from_naver_detail(entry['detail_url'])
|
||||
listed_raw = (info.get('lcalDate') or info.get('listingDate') or info.get('listDate') or '').strip()
|
||||
if not listed_raw or '미정' in listed_raw:
|
||||
continue
|
||||
try:
|
||||
d = date.fromisoformat(listed_raw)
|
||||
except ValueError:
|
||||
continue
|
||||
if d <= cutoff:
|
||||
continue
|
||||
events.append(EventSpec('listing', info.get('compName') or entry['name'], d, d, brokers, entry['detail_url']))
|
||||
return events
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_state(state: dict):
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def event_key_from_summary(summary: str) -> tuple[str, str] | None:
|
||||
summary = (summary or '').strip()
|
||||
if summary.startswith('[공모청약] '):
|
||||
return ('subscription', summary.replace('[공모청약] ', '', 1).strip())
|
||||
if summary.startswith('[신규상장] '):
|
||||
return ('listing', summary.replace('[신규상장] ', '', 1).strip())
|
||||
return None
|
||||
|
||||
|
||||
def fetch_existing_events(start_date: date, end_date: date) -> dict[str, dict]:
|
||||
start_dt = datetime(start_date.year, start_date.month, start_date.day, 0, 0, tzinfo=KST)
|
||||
end_dt = datetime(end_date.year, end_date.month, end_date.day, 0, 0, tzinfo=KST)
|
||||
out = run([
|
||||
'gog', 'calendar', 'events', CALENDAR_ID,
|
||||
'--from', start_dt.isoformat(), '--to', end_dt.isoformat(),
|
||||
'--all-pages', '--max', '250', '--json'
|
||||
])
|
||||
data = json.loads(out)
|
||||
existing: dict[str, list[dict]] = {}
|
||||
for ev in data.get('events', []):
|
||||
parsed = event_key_from_summary(ev.get('summary', ''))
|
||||
if not parsed:
|
||||
continue
|
||||
kind, name = parsed
|
||||
key = f'{kind}|{name}'
|
||||
existing.setdefault(key, []).append(ev)
|
||||
return existing
|
||||
|
||||
|
||||
def create_event(ev: EventSpec, dry_run: bool = False):
|
||||
start_date = ev.start_date.isoformat()
|
||||
end_date = (ev.end_date + timedelta(days=1)).isoformat() # Google Calendar 종일 이벤트는 end가 exclusive
|
||||
if dry_run:
|
||||
print(json.dumps({'summary': ev.summary, 'date': start_date, 'description': ev.description, 'all_day': True}, ensure_ascii=False))
|
||||
return
|
||||
run([
|
||||
'gog', 'calendar', 'create', CALENDAR_ID,
|
||||
'--summary', ev.summary,
|
||||
'--description', ev.description,
|
||||
'--from', start_date,
|
||||
'--to', end_date,
|
||||
'--all-day',
|
||||
'--event-color', '5' if ev.kind == 'subscription' else '10'
|
||||
])
|
||||
|
||||
|
||||
def update_event(event_id: str, ev: EventSpec, dry_run: bool = False):
|
||||
start_date = ev.start_date.isoformat()
|
||||
end_date = (ev.end_date + timedelta(days=1)).isoformat()
|
||||
if dry_run:
|
||||
print(json.dumps({'update_event_id': event_id, 'summary': ev.summary, 'date': start_date, 'description': ev.description, 'all_day': True}, ensure_ascii=False))
|
||||
return 'updated'
|
||||
try:
|
||||
run([
|
||||
'gog', 'calendar', 'update', CALENDAR_ID, event_id,
|
||||
'--summary', ev.summary,
|
||||
'--description', ev.description,
|
||||
'--from', start_date,
|
||||
'--to', end_date,
|
||||
'--all-day',
|
||||
'--event-color', '5' if ev.kind == 'subscription' else '10'
|
||||
])
|
||||
return 'updated'
|
||||
except Exception:
|
||||
run(['gog', 'calendar', 'delete', CALENDAR_ID, event_id, '--force', '--no-input'])
|
||||
create_event(ev, dry_run=False)
|
||||
return 'recreated'
|
||||
|
||||
|
||||
def delete_event(event_id: str, dry_run: bool = False):
|
||||
if dry_run:
|
||||
print(json.dumps({'delete_event_id': event_id}, ensure_ascii=False))
|
||||
return
|
||||
run(['gog', 'calendar', 'delete', CALENDAR_ID, event_id, '--force', '--no-input'])
|
||||
|
||||
|
||||
def event_date_range(ev: dict) -> tuple[str, str]:
|
||||
start_info = ev.get('start', {})
|
||||
end_info = ev.get('end', {})
|
||||
start = start_info.get('date') or start_info.get('dateTime', '')[:10]
|
||||
end_excl = end_info.get('date') or end_info.get('dateTime', '')[:10]
|
||||
end = ''
|
||||
if end_excl:
|
||||
end = (date.fromisoformat(end_excl) - timedelta(days=1)).isoformat()
|
||||
return start, end
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
cutoff = datetime.now(KST).date()
|
||||
events = parse_subscription_events_from(cutoff) + parse_listed_events_from(cutoff)
|
||||
events.sort(key=lambda e: (e.start_date, e.kind, e.name))
|
||||
|
||||
state = load_state()
|
||||
end_date = max((ev.end_date for ev in events), default=cutoff) + timedelta(days=1)
|
||||
# 사이트 스크래핑 실패 시 모든 일정이 삭제되는 사고 방지 — events 비면 cleanup 스킵
|
||||
existing = {} if not events else fetch_existing_events(cutoff + timedelta(days=1), end_date)
|
||||
created = []
|
||||
updated = []
|
||||
recreated = []
|
||||
deleted = []
|
||||
duplicates_removed = []
|
||||
unchanged = 0
|
||||
changes = []
|
||||
|
||||
event_keys = {ev.state_key for ev in events}
|
||||
|
||||
for ev in events:
|
||||
existing_list = existing.get(ev.state_key, [])
|
||||
# 같은 state_key로 여러 건이면 가장 최근 created를 남기고 나머지 삭제
|
||||
if len(existing_list) > 1:
|
||||
existing_list.sort(key=lambda e: e.get('created', ''), reverse=True)
|
||||
for dup in existing_list[1:]:
|
||||
delete_event(dup['id'], dry_run=args.dry_run)
|
||||
duplicates_removed.append(ev.state_key)
|
||||
old_start, old_end = event_date_range(dup)
|
||||
old_date = old_start if old_start == old_end or not old_end else f'{old_start}~{old_end}'
|
||||
changes.append({'kind': ev.kind, 'name': ev.name, 'action': 'duplicate_removed', 'event_id': dup['id'], 'old_date': old_date})
|
||||
|
||||
existing_ev = existing_list[0] if existing_list else None
|
||||
|
||||
if not existing_ev:
|
||||
create_event(ev, dry_run=args.dry_run)
|
||||
created.append(ev.state_key)
|
||||
changes.append({'kind': ev.kind, 'name': ev.name, 'action': 'created', 'new_date': ev.start_date.isoformat() if ev.start_date == ev.end_date else f'{ev.start_date.isoformat()}~{ev.end_date.isoformat()}'})
|
||||
continue
|
||||
|
||||
existing_start, existing_end = event_date_range(existing_ev)
|
||||
desired_start = ev.start_date.isoformat()
|
||||
desired_end = ev.end_date.isoformat()
|
||||
existing_description = (existing_ev.get('description') or '').strip()
|
||||
desired_description = ev.description.strip()
|
||||
|
||||
if existing_start != desired_start or existing_end != desired_end or existing_description != desired_description:
|
||||
result = update_event(existing_ev['id'], ev, dry_run=args.dry_run)
|
||||
if result == 'recreated':
|
||||
recreated.append(ev.state_key)
|
||||
else:
|
||||
updated.append(ev.state_key)
|
||||
old_date = existing_start if existing_start == existing_end or not existing_end else f'{existing_start}~{existing_end}'
|
||||
new_date = desired_start if desired_start == desired_end else f'{desired_start}~{desired_end}'
|
||||
changes.append({'kind': ev.kind, 'name': ev.name, 'action': result, 'old_date': old_date, 'new_date': new_date})
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
# 사이트에서 사라진 일정은 캘린더에서도 삭제 (events 비면 위에서 existing이 빈 dict라 자동으로 스킵됨)
|
||||
for key, existing_list in existing.items():
|
||||
if key in event_keys:
|
||||
continue
|
||||
kind, name = key.split('|', 1)
|
||||
for stale in existing_list:
|
||||
delete_event(stale['id'], dry_run=args.dry_run)
|
||||
deleted.append(key)
|
||||
old_start, old_end = event_date_range(stale)
|
||||
old_date = old_start if old_start == old_end or not old_end else f'{old_start}~{old_end}'
|
||||
changes.append({'kind': kind, 'name': name, 'action': 'deleted', 'event_id': stale['id'], 'old_date': old_date})
|
||||
|
||||
state['last_changes'] = changes
|
||||
state['last_run_at'] = datetime.now(KST).isoformat()
|
||||
if not args.dry_run:
|
||||
save_state(state)
|
||||
|
||||
print(json.dumps({'cutoff_after': cutoff.isoformat(), 'total_found': len(events), 'newly_created': len(created), 'updated': len(updated), 'recreated': len(recreated), 'deleted': len(deleted), 'duplicates_removed': len(duplicates_removed), 'unchanged': unchanged, 'dry_run': args.dry_run, 'changes': changes}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""KOSPI/KOSDAQ 일별 시장지표(ADR·투자자별 매매) 누적.
|
||||
|
||||
네이버 m.stock 비공식 API에서 한 콜로 받아 `state/market_indicators_history.jsonl`에 적재.
|
||||
KRX 정보데이터시스템은 응답 패턴이 자주 바뀌어 별도 의존성 도입 전엔 보류 (2026-05 결정).
|
||||
|
||||
CLI:
|
||||
collect [--force] [--quiet] # 휴장/주말은 self-skip (--force 우회)
|
||||
|
||||
idempotent: 같은 (date, market) 행 발견 시 제거 후 재적재.
|
||||
launchd 트리거: 평일 17:30 — 정규장 마감(15:30) 동시호가 정산 안정화 후.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
HISTORY = WORKSPACE / 'state' / 'market_indicators_history.jsonl'
|
||||
HOLIDAYS = WORKSPACE / 'state' / 'market_holidays.json'
|
||||
MARKETS = (('KOSPI', '코스피'), ('KOSDAQ', '코스닥'))
|
||||
|
||||
|
||||
def is_market_open(d: datetime) -> bool:
|
||||
if d.weekday() >= 5:
|
||||
return False
|
||||
iso = d.strftime('%Y-%m-%d')
|
||||
try:
|
||||
data = json.loads(HOLIDAYS.read_text())
|
||||
return iso not in data.get('holidays', {})
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
|
||||
|
||||
def _parse_signed_int(s) -> int | None:
|
||||
if s is None:
|
||||
return None
|
||||
try:
|
||||
return int(str(s).replace(',', '').replace('+', '').strip())
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_count(s) -> int:
|
||||
if s is None:
|
||||
return 0
|
||||
try:
|
||||
return int(str(s).replace(',', '').strip())
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def fetch_market(symbol: str, label: str) -> dict | None:
|
||||
url = f'https://m.stock.naver.com/api/index/{symbol}/integration'
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=5.0) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8', 'ignore'))
|
||||
up_down = data.get('upDownStockInfo') or {}
|
||||
rise = _parse_count(up_down.get('riseCount'))
|
||||
upper = _parse_count(up_down.get('upperCount'))
|
||||
fall = _parse_count(up_down.get('fallCount'))
|
||||
lower = _parse_count(up_down.get('lowerCount'))
|
||||
steady = _parse_count(up_down.get('steadyCount'))
|
||||
adv = rise + upper
|
||||
dec = fall + lower
|
||||
adr = round(adv / dec * 100.0, 2) if dec else None
|
||||
deal = data.get('dealTrendInfo') or {}
|
||||
bizdate = str(deal.get('bizdate') or '').strip() or None
|
||||
return {
|
||||
'market': symbol,
|
||||
'market_label': label,
|
||||
'bizdate': bizdate,
|
||||
'rise': rise,
|
||||
'upper': upper,
|
||||
'fall': fall,
|
||||
'lower': lower,
|
||||
'steady': steady,
|
||||
'adr': adr,
|
||||
'personal': _parse_signed_int(deal.get('personalValue')),
|
||||
'foreign': _parse_signed_int(deal.get('foreignValue')),
|
||||
'institutional': _parse_signed_int(deal.get('institutionalValue')),
|
||||
}
|
||||
|
||||
|
||||
def _load_all() -> list[dict]:
|
||||
if not HISTORY.exists():
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for ln in HISTORY.read_text().splitlines():
|
||||
ln = ln.strip()
|
||||
if not ln:
|
||||
continue
|
||||
try:
|
||||
out.append(json.loads(ln))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _save_all(rows: list[dict]) -> None:
|
||||
tmp = HISTORY.with_suffix('.jsonl.tmp')
|
||||
if rows:
|
||||
tmp.write_text('\n'.join(json.dumps(r, ensure_ascii=False) for r in rows) + '\n')
|
||||
else:
|
||||
tmp.write_text('')
|
||||
tmp.replace(HISTORY)
|
||||
|
||||
|
||||
def collect(force: bool = False, quiet: bool = False) -> int:
|
||||
now = datetime.now(KST)
|
||||
if not force and not is_market_open(now):
|
||||
if not quiet:
|
||||
print(f'[skip] {now.strftime("%Y-%m-%d %H:%M %Z")} — 휴장/주말 (--force 우회)')
|
||||
return 0
|
||||
|
||||
new_rows: list[dict] = []
|
||||
for symbol, label in MARKETS:
|
||||
try:
|
||||
r = fetch_market(symbol, label)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[error] {symbol}: {e}\n')
|
||||
return 2
|
||||
if not r or not r.get('bizdate'):
|
||||
sys.stderr.write(f'[error] {symbol}: bizdate 없음 — 응답 이상\n')
|
||||
return 2
|
||||
bd = r['bizdate']
|
||||
iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if len(bd) == 8 else bd
|
||||
new_rows.append({
|
||||
'date': iso,
|
||||
'market': symbol,
|
||||
'market_label': label,
|
||||
'rise': r['rise'],
|
||||
'upper': r['upper'],
|
||||
'fall': r['fall'],
|
||||
'lower': r['lower'],
|
||||
'steady': r['steady'],
|
||||
'adr': r['adr'],
|
||||
'personal': r['personal'],
|
||||
'foreign': r['foreign'],
|
||||
'institutional': r['institutional'],
|
||||
'captured_at': now.isoformat(),
|
||||
})
|
||||
|
||||
existing = _load_all()
|
||||
new_keys = {(r['date'], r['market']) for r in new_rows}
|
||||
keep = [r for r in existing if (r.get('date'), r.get('market')) not in new_keys]
|
||||
merged = sorted(keep + new_rows, key=lambda r: (r.get('date', ''), r.get('market', '')))
|
||||
_save_all(merged)
|
||||
|
||||
if not quiet:
|
||||
for r in new_rows:
|
||||
adr_s = f'{r["adr"]:.1f}' if r['adr'] is not None else '—'
|
||||
print(f'[collect] {r["date"]} {r["market"]:6s} ADR={adr_s} '
|
||||
f'rise={r["rise"]} fall={r["fall"]} '
|
||||
f'개인={r["personal"]} 외국인={r["foreign"]} 기관={r["institutional"]}')
|
||||
print(f'[ok] saved={len(new_rows)} total_rows={len(merged)} -> {HISTORY}')
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description='KOSPI/KOSDAQ 일별 ADR·투자자별 매매 누적')
|
||||
sub = p.add_subparsers(dest='cmd', required=True)
|
||||
c = sub.add_parser('collect', help='오늘 데이터 1회 누적')
|
||||
c.add_argument('--force', action='store_true', help='휴장/주말도 강제 실행')
|
||||
c.add_argument('--quiet', action='store_true', help='성공 로그 생략')
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.cmd == 'collect':
|
||||
return collect(force=args.force, quiet=args.quiet)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
OpenClaw Stock Agent — Order Module (orders/)
|
||||
|
||||
이 패키지는 키움 REST API 로 매수·매도 주문을 발행하는 유일한 통로입니다.
|
||||
조회 전용 모듈(`agents/stock/workspace/scripts/kiwoom_client.py`)와 분리됩니다.
|
||||
|
||||
매매 절대 원칙
|
||||
==============
|
||||
1. LLM 결정 금지
|
||||
- LLM(클로·레이) 출력으로 매매 트리거 안 됨.
|
||||
- 자연어 → 페이로드 파싱은 LLM 가능, 단 사람의 PIN echo 가 마지막 게이트.
|
||||
- 환경변수 OPENCLAW_AGENT 가 셋된 세션은 즉시 sys.exit(99). 진입점 첫 줄 강제.
|
||||
|
||||
2. DM + chat_id 화이트리스트
|
||||
- 그룹·익명 메시지는 즉시 거부.
|
||||
- 토큰 검증 시 발신자 ID 화이트리스트 통과해야 실행.
|
||||
|
||||
3. 사이드카 default OFF
|
||||
- state/orders_disabled 파일 존재 시 모든 진입점 첫 줄에서 거부.
|
||||
- /orders_on 으로 풀고, /orders_off 또는 trash 로 막음.
|
||||
|
||||
4. 한도·시간·종목 가드는 limits.json 으로 관리
|
||||
- 코드 상수 X. 변경 시 별도 커밋, 단위테스트 강제.
|
||||
- guards.py 가 limits.json 을 읽어 검증.
|
||||
|
||||
5. 자동 재시도 금지
|
||||
- 네트워크 timeout 후 자동 재시도 안 함 (중복 체결 위험).
|
||||
- 실패 시 사람이 ord_no 조회로 체결 확인 후 재판단.
|
||||
|
||||
확정 사양
|
||||
=========
|
||||
- 매매 허용 계좌: 본인 4계좌 (가희 포함)
|
||||
- 1회 주문 / 1일 누적 / 잔고% 한도: 없음
|
||||
- 가격 가드: ±30% (상한가/하한가) 초과 지정가 거부 (시장가에는 적용 불가, 거래소가 자연 차단)
|
||||
- 시장가: 허용
|
||||
- 자연어 "시장가" → 시장가 주문
|
||||
- 자연어 "지금 바로 / 즉시 / 빨리 / 당장" → 최우선호가 +1틱 지정가 (모호함 보호)
|
||||
- 거래시간: 08:00–20:00 (NXT 포함)
|
||||
- 08:00–09:00 NXT 단독 (NXT 미가능 종목 거부)
|
||||
- 09:00:30–15:20 KRX + NXT 동시 (SOR)
|
||||
- 15:20–15:30 KRX 단일가
|
||||
- 15:30–20:00 NXT 단독 (NXT 미가능 종목 거부)
|
||||
- 라우팅 default: SOR (_AL) 자동
|
||||
- 거래 간 딜레이: 마지막 카드 종료(체결·만료·취소) 후 60초
|
||||
- 동일 종목 딜레이: 마지막 체결 후 600초 (10분)
|
||||
- PIN
|
||||
- 본인 계좌(일반·ISA): 숫자 4자리
|
||||
- 가희 계좌(가희_일반·가희_ISA): 영숫자 8자리, 혼동 글자 제외 55자
|
||||
- 만료 120초, 1회용, 1회 시도
|
||||
- 카드+PIN 분리 발송: 메시지 A(카드, 매수/매도·계좌·종목명·가격 하이라이트) + 메시지 B(PIN만 단독)
|
||||
- 만료/취소/오입력: 모두 텔레그램 알림
|
||||
- 사이드카: /orders_on /orders_off
|
||||
|
||||
상세 한도값과 시간 경계는 limits.json 참조.
|
||||
"""
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
텔레그램 카드 메시지 포맷터.
|
||||
|
||||
매수/매도, 계좌, 종목명, 가격 4개를 ▶ 마커 + *bold* (텔레그램 Markdown)로 하이라이트.
|
||||
시장가일 때 호가창 + 평균체결가 + 슬리피지% 표시.
|
||||
PIN 메시지는 PIN 만 단독 — 메타 정보 일체 없음.
|
||||
가희 계좌는 카드 헤더에 🔐 마커 추가.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
|
||||
|
||||
ACCOUNT_DISPLAY = {
|
||||
'일반': '본인 일반',
|
||||
'ISA': '본인 ISA',
|
||||
'가희_일반': '가희 일반',
|
||||
'가희_ISA': '가희 ISA',
|
||||
}
|
||||
|
||||
|
||||
def _limits() -> dict:
|
||||
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def _is_spouse(account: str) -> bool:
|
||||
return account in _limits()['spouse_accounts']
|
||||
|
||||
|
||||
def _account_display(account: str) -> str:
|
||||
return ACCOUNT_DISPLAY.get(account, account)
|
||||
|
||||
|
||||
def _md_bold(s: str) -> str:
|
||||
return f'*{s}*'
|
||||
|
||||
|
||||
def _money(n) -> str:
|
||||
return f'{int(n):,}원'
|
||||
|
||||
|
||||
def _pct(p: float, digits: int = 2) -> str:
|
||||
if abs(p) < 10 ** -digits:
|
||||
p = 0.0
|
||||
sign = '+' if p > 0 else ''
|
||||
return f'{sign}{p:.{digits}f}%'
|
||||
|
||||
|
||||
def format_card(request: dict, market_data: dict, card_id: str,
|
||||
estimate: Optional[dict] = None,
|
||||
budget_conversion: Optional[dict] = None,
|
||||
state_warning: Optional[str] = None,
|
||||
amended: bool = False) -> str:
|
||||
cfg = _limits()['card']
|
||||
side = request['side']
|
||||
side_word = '매수' if side == 'BUY' else '매도'
|
||||
side_emoji = cfg['buy_emoji'] if side == 'BUY' else cfg['sell_emoji']
|
||||
marker = cfg['highlight_marker']
|
||||
spouse_marker = ' 🔐 가희 계좌' if _is_spouse(request['account']) else ''
|
||||
type_marker = ''
|
||||
if request['order_type'] == 'MARKET':
|
||||
type_marker = f' {cfg["warning_emoji"]} 시장가'
|
||||
elif request['order_type'] == 'AGGRESSIVE_LIMIT':
|
||||
type_marker = f' {cfg["warning_emoji"]} 공격적 지정가'
|
||||
amend_marker = ' ✏️ 수정됨' if amended else ''
|
||||
|
||||
lines = [f'{side_emoji} {_md_bold(side_word + " 미리보기")} [#{card_id}]{type_marker}{spouse_marker}{amend_marker}', '']
|
||||
|
||||
lines.append(f'{marker} {_md_bold(side_word)}')
|
||||
lines.append(f'{marker} 계좌: {_md_bold(_account_display(request["account"]))}')
|
||||
symbol_name = request.get('symbol_name', request['symbol'])
|
||||
lines.append(f'{marker} 종목: {_md_bold(symbol_name)} ({request["symbol"]})')
|
||||
if state_warning:
|
||||
lines.append(state_warning)
|
||||
|
||||
if request['order_type'] == 'LIMIT':
|
||||
price_part = _md_bold(_money(request['price']))
|
||||
if market_data.get('prev_close'):
|
||||
ratio = (request['price'] - market_data['prev_close']) / market_data['prev_close'] * 100
|
||||
price_part += f' (전일종가 {_pct(ratio)})'
|
||||
lines.append(f'{marker} 가격: {price_part}')
|
||||
elif request['order_type'] == 'MARKET':
|
||||
lines.append(f'{marker} 가격: {_md_bold("시장가")}')
|
||||
else:
|
||||
if estimate and 'aggressive_price' in estimate:
|
||||
tail = '최우선호가+1틱'
|
||||
if estimate.get('source') == 'fallback':
|
||||
now_dt = market_data.get('now')
|
||||
tm = now_dt.strftime('%H:%M') if now_dt else ''
|
||||
tm_part = f' {tm}' if tm else ''
|
||||
tail = f'호가창 비어 현재가{tm_part} +1틱'
|
||||
lines.append(f'{marker} 가격: {_md_bold(_money(estimate["aggressive_price"]))} ({tail})')
|
||||
else:
|
||||
lines.append(f'{marker} 가격: {_md_bold("최우선호가+1틱")}')
|
||||
|
||||
lines.append('')
|
||||
qty_line = f'수량: {request["qty"]:,}주'
|
||||
if budget_conversion:
|
||||
if budget_conversion.get('source') == 'fallback':
|
||||
now_dt = market_data.get('now')
|
||||
tm = now_dt.strftime('%H:%M') if now_dt else ''
|
||||
tm_part = f' {tm}' if tm else ''
|
||||
ref_label = f'호가창 비어 현재가{tm_part}'
|
||||
else:
|
||||
ref_label = '매도1호가' if side == 'BUY' else '매수1호가'
|
||||
rem = budget_conversion['remainder']
|
||||
if budget_conversion.get('bumped'):
|
||||
rem_part = f'1주 추가 매수, 초과액 {_money(abs(rem))}'
|
||||
else:
|
||||
rem_part = f'잔액 {_money(rem)}'
|
||||
qty_line += (f' (예산 {_money(budget_conversion["budget"])} → '
|
||||
f'{ref_label} {_money(budget_conversion["ref_price"])} 기준 환산, '
|
||||
f'{rem_part})')
|
||||
lines.append(qty_line)
|
||||
|
||||
if request['order_type'] == 'MARKET':
|
||||
lines.append(f'현재가: {_money(market_data["current_price"])}')
|
||||
ob = market_data.get('orderbook') or {}
|
||||
levels = (ob.get('asks' if side == 'BUY' else 'bids') or [])[:cfg['orderbook_depth']]
|
||||
label = '매도' if side == 'BUY' else '매수'
|
||||
for i, lvl in enumerate(levels, 1):
|
||||
lines.append(f'{label}{i}호가: {_money(lvl["price"])} (잔량 {lvl["qty"]:,}주)')
|
||||
if estimate:
|
||||
lines.append('')
|
||||
money_label = '예상 금액' if side == 'BUY' else '예상 회수'
|
||||
lines.append(f'예상 평균체결가: {_money(estimate["avg_fill"])}')
|
||||
lines.append(f'{money_label}: 약 {_money(estimate["total_won"])}')
|
||||
lines.append(f'예상 슬리피지: {_pct(estimate["slippage_pct"], 3)}')
|
||||
else:
|
||||
price = request.get('price') or (estimate and estimate.get('aggressive_price'))
|
||||
if price:
|
||||
money_label = '예상 금액' if side == 'BUY' else '예상 회수'
|
||||
lines.append(f'{money_label}: {_money(price * request["qty"])}')
|
||||
|
||||
if cfg['show_balance_ratio']:
|
||||
if side == 'BUY' and market_data.get('balance_d2') is not None:
|
||||
balance = market_data['balance_d2']
|
||||
need = (estimate['total_won'] if estimate and request['order_type'] == 'MARKET'
|
||||
else (request.get('price') or (estimate and estimate.get('aggressive_price')) or 0) * request['qty'])
|
||||
if balance and need:
|
||||
ratio = need / balance * 100
|
||||
lines.append(f'매수가능금액: {_money(balance)} (잔고대비 {ratio:.1f}%)')
|
||||
else:
|
||||
lines.append(f'매수가능금액: {_money(balance)}')
|
||||
if side == 'SELL' and market_data.get('position_qty') is not None:
|
||||
lines.append(f'보유수량: {market_data["position_qty"]:,}주')
|
||||
|
||||
lines.append(f'{_limits()["pin"]["expiry_seconds"]}초 후 만료')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def format_pin_message(pin: str) -> str:
|
||||
return pin
|
||||
|
||||
|
||||
def format_filled(card_id: str, side: str, symbol_name: str, qty: int,
|
||||
fill_price: int, ord_no: str, remaining_balance: Optional[int] = None) -> str:
|
||||
side_word = '매수' if side == 'BUY' else '매도'
|
||||
out = [f'✅ [#{card_id}] 체결: {symbol_name} {qty:,}주 @ {_money(fill_price)} ({side_word})',
|
||||
f'주문번호: {ord_no}']
|
||||
if remaining_balance is not None:
|
||||
out.append(f'잔여 예수금: {_money(remaining_balance)}')
|
||||
return '\n'.join(out)
|
||||
|
||||
|
||||
def format_submitted(card_id: str, side: str, symbol_name: str, qty: int,
|
||||
price: Optional[int], ord_no: str) -> str:
|
||||
side_word = '매수' if side == 'BUY' else '매도'
|
||||
price_str = _money(price) if price else '시장가'
|
||||
return f'📨 [#{card_id}] {side_word} 접수: {symbol_name} {qty:,}주 @ {price_str}\n주문번호: {ord_no}'
|
||||
|
||||
|
||||
def format_expired(card_id: str, side: str) -> str:
|
||||
side_word = '매수' if side == 'BUY' else '매도'
|
||||
return f'⏱️ [#{card_id}] 승인 만료. {side_word}가 취소되었습니다.'
|
||||
|
||||
|
||||
def format_canceled(card_id: str, side: str) -> str:
|
||||
side_word = '매수' if side == 'BUY' else '매도'
|
||||
return f'❌ [#{card_id}] {side_word} 취소되었습니다.'
|
||||
|
||||
|
||||
def format_pin_mismatch(card_id: str) -> str:
|
||||
return f'❌ [#{card_id}] PIN 불일치. 카드 무효. 다시 신호 보내주세요.'
|
||||
|
||||
|
||||
def format_partial(card_id: str, side: str, symbol_name: str,
|
||||
cntr_qty: int, order_qty: int, fill_price: int, ord_no: str) -> str:
|
||||
side_word = '매수' if side == 'BUY' else '매도'
|
||||
return (f'🟡 [#{card_id}] {side_word} 부분체결: {symbol_name} '
|
||||
f'{cntr_qty:,}/{order_qty:,}주 @ {_money(fill_price)}\n주문번호: {ord_no}')
|
||||
|
||||
|
||||
def format_broker_post_reject(card_id: str, side: str, symbol_name: str,
|
||||
ord_no: str, mdfy_cncl: str) -> str:
|
||||
side_word = '매수' if side == 'BUY' else '매도'
|
||||
return (f'❌ [#{card_id}] {side_word} 사후거절({mdfy_cncl}): {symbol_name}\n주문번호: {ord_no}')
|
||||
|
||||
|
||||
def format_unfilled_timeout(card_id: str, side: str, symbol_name: str,
|
||||
cntr_qty: int, order_qty: int, ord_no: str) -> str:
|
||||
side_word = '매수' if side == 'BUY' else '매도'
|
||||
if cntr_qty == 0:
|
||||
head = f'⏱️ [#{card_id}] {side_word} 30분째 미체결: {symbol_name} 0/{order_qty:,}주'
|
||||
else:
|
||||
head = (f'⏱️ [#{card_id}] {side_word} 30분 추적 종료 (부분체결): '
|
||||
f'{symbol_name} {cntr_qty:,}/{order_qty:,}주')
|
||||
return (f'{head}\n주문번호: {ord_no}\n'
|
||||
f'필요 시 키움 직접 정정/취소 또는 추가 추적 명령')
|
||||
|
||||
|
||||
def format_cancel_confirmed(side: str, symbol_name: str, orig_ord_no: str,
|
||||
new_ord_no: str, cancel_qty: int) -> str:
|
||||
side_word = '매수' if side == 'BUY' else ('매도' if side == 'SELL' else side)
|
||||
return (f'✅ {side_word} 취소 확인: {symbol_name} {cancel_qty:,}주 취소됨\n'
|
||||
f'원주문: {orig_ord_no} / 취소주문: {new_ord_no}')
|
||||
|
||||
|
||||
def format_cancel_unconfirmed_timeout(side: str, symbol_name: str,
|
||||
orig_ord_no: str, new_ord_no: str) -> str:
|
||||
side_word = '매수' if side == 'BUY' else ('매도' if side == 'SELL' else side)
|
||||
return (f'⏱️ {side_word} 취소 30분째 미확인: {symbol_name}\n'
|
||||
f'원주문: {orig_ord_no} / 취소주문: {new_ord_no}\n'
|
||||
f'키움에서 직접 확인 필요')
|
||||
|
||||
|
||||
def format_rejected(code: str, message: str) -> str:
|
||||
return f'⛔ 거부 [{code}]: {message}'
|
||||
|
||||
|
||||
def format_sidecar_blocked() -> str:
|
||||
return '🚫 매매 비활성화 상태. /orders_on 으로 재개하세요.'
|
||||
|
||||
|
||||
def format_card_locked(active: Optional[dict] = None) -> str:
|
||||
header = '⏳ 이전 카드가 아직 활성 — 처리 후 다시 신호 주세요.'
|
||||
if not active:
|
||||
return header
|
||||
|
||||
side_word = '매수' if active.get('side') == 'BUY' else '매도'
|
||||
account = _account_display(active.get('account', ''))
|
||||
name = active.get('symbol_name') or active.get('symbol') or ''
|
||||
qty = active.get('qty')
|
||||
order_type = active.get('order_type')
|
||||
price = active.get('price')
|
||||
if order_type == 'MARKET':
|
||||
price_str = '시장가'
|
||||
elif order_type == 'AGGRESSIVE_LIMIT':
|
||||
price_str = f'공격적 지정가 {_money(price)}' if price else '공격적 지정가'
|
||||
else:
|
||||
price_str = _money(price) if price is not None else '지정가'
|
||||
|
||||
qty_str = f'{qty}주' if qty is not None else ''
|
||||
desc = ' · '.join(p for p in [account, side_word, f'{name} {qty_str}'.strip(), price_str] if p)
|
||||
|
||||
expires_in = int(active.get('expires_in') or 0)
|
||||
return '\n'.join([
|
||||
header,
|
||||
f' [#{active.get("card_id", "?")}] {desc}',
|
||||
f' 남은 시간: {expires_in}초 (또는 /cancel 로 즉시 폐기)',
|
||||
])
|
||||
|
||||
|
||||
def format_dryrun(payload: dict) -> str:
|
||||
return '🧪 DRYRUN — 실주문 안 함\n' + json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
키움 REST API → market_data dict.
|
||||
|
||||
guards.validate_request 가 요구하는 모든 필드를 채워 반환한다.
|
||||
기존 kiwoom_client 의 조회 함수를 재사용.
|
||||
|
||||
호가창(ka10004)·NXT 가능여부 등 일부 필드는 키움 응답 필드명이 환경마다 다를 수 있어
|
||||
best-effort 파싱이며, 실패 시 None/0 으로 떨어진다. 첫 실거래 검증 시 실데이터로 보강 필요.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_PARENT = Path(__file__).resolve().parent.parent
|
||||
if str(_PARENT) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT))
|
||||
|
||||
import kiwoom_client as kc
|
||||
from . import guards
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def _to_int(s) -> int:
|
||||
if s is None or s == '':
|
||||
return 0
|
||||
try:
|
||||
return int(str(s).replace(',', '').replace('+', '').strip() or 0)
|
||||
except (ValueError, AttributeError):
|
||||
return 0
|
||||
|
||||
|
||||
def _safe_quote(symbol: str, account_label: str) -> dict:
|
||||
"""ka10001 (주식기본정보) 응답에서 가드용 필드 추출.
|
||||
|
||||
공식 명세 필드명:
|
||||
- cur_prc: 현재가, base_pric: 기준가(전일종가)
|
||||
- upl_pric: 상한가, lst_pric: 하한가
|
||||
- 거래정지(halt) 필드는 ka10001 명세에 없음 — 별도 TR 보강 필요. 보수적으로 False.
|
||||
"""
|
||||
try:
|
||||
q_wrap = kc.get_stock_quote(symbol, account_label=account_label, exchange='AL')
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(q_wrap, dict):
|
||||
return {}
|
||||
raw = q_wrap.get('raw') if isinstance(q_wrap.get('raw'), dict) else q_wrap
|
||||
out = {
|
||||
'cur_price': abs(_to_int(raw.get('cur_prc'))),
|
||||
'prev_close': abs(_to_int(raw.get('base_pric'))),
|
||||
'upper_limit': abs(_to_int(raw.get('upl_pric'))),
|
||||
'lower_limit': abs(_to_int(raw.get('lst_pric'))),
|
||||
'halt': False, # ka10001 명세에 거래정지 플래그 없음 — 별도 TR 보강 예정
|
||||
}
|
||||
out['_raw'] = raw
|
||||
return out
|
||||
|
||||
|
||||
def _safe_orderbook(symbol: str, account_label: str) -> Optional[dict]:
|
||||
"""ka10004 (주식호가요청) — /api/dostk/mrkcond.
|
||||
|
||||
필드 매핑 (키움 공식 명세):
|
||||
- 1호가: sel_fpr_bid / sel_fpr_req (매도), buy_fpr_bid / buy_fpr_req (매수)
|
||||
- 2~10호가: sel_{N}th_pre_bid / sel_{N}th_pre_req (매도), buy_{N}th_pre_bid / buy_{N}th_pre_req (매수)
|
||||
가격에 부호(+/-) 붙어올 수 있어 abs 처리.
|
||||
"""
|
||||
try:
|
||||
resp = kc._call(account_label, 'ka10004', {'stk_cd': symbol},
|
||||
endpoint=kc.ENDPOINT_MRKCOND)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(resp, dict):
|
||||
return None
|
||||
asks, bids = [], []
|
||||
# 1호가
|
||||
ap1 = abs(_to_int(resp.get('sel_fpr_bid')))
|
||||
aq1 = abs(_to_int(resp.get('sel_fpr_req')))
|
||||
if ap1 and aq1:
|
||||
asks.append({'price': ap1, 'qty': aq1})
|
||||
bp1 = abs(_to_int(resp.get('buy_fpr_bid')))
|
||||
bq1 = abs(_to_int(resp.get('buy_fpr_req')))
|
||||
if bp1 and bq1:
|
||||
bids.append({'price': bp1, 'qty': bq1})
|
||||
# 2~10호가
|
||||
for i in range(2, 11):
|
||||
ap = abs(_to_int(resp.get(f'sel_{i}th_pre_bid')))
|
||||
aq = abs(_to_int(resp.get(f'sel_{i}th_pre_req')))
|
||||
if ap and aq:
|
||||
asks.append({'price': ap, 'qty': aq})
|
||||
bp = abs(_to_int(resp.get(f'buy_{i}th_pre_bid')))
|
||||
bq = abs(_to_int(resp.get(f'buy_{i}th_pre_req')))
|
||||
if bp and bq:
|
||||
bids.append({'price': bp, 'qty': bq})
|
||||
if not asks and not bids:
|
||||
return None
|
||||
return {'asks': asks, 'bids': bids}
|
||||
|
||||
|
||||
def _nxt_eligible(symbol: str, quote: dict) -> bool:
|
||||
"""NXT 거래가능 여부.
|
||||
|
||||
1차 소스: ka10099 종목정보 캐시 (`state/stock_codes.json`)의 `nxt_enable` 플래그.
|
||||
캐시 미스 또는 구 스키마(필드 없음) → 보수적 True (= 가드 통과 → 키움이 사후 거부).
|
||||
캐시 갱신 명령: `python3 kiwoom_client.py refresh-codes`.
|
||||
"""
|
||||
try:
|
||||
meta = kc.lookup_stock_meta(symbol)
|
||||
except Exception:
|
||||
return True
|
||||
if not meta:
|
||||
return True
|
||||
if 'nxt_enable' not in meta: # 구 스키마 캐시 — 갱신 전엔 보수적 True
|
||||
return True
|
||||
return bool(meta['nxt_enable'])
|
||||
|
||||
|
||||
def _safe_broker_executions(account_label: str, now: datetime) -> tuple[Optional[list], Optional[str]]:
|
||||
"""kt00007 기준 오늘자 매매 행 조회. 실패 시 (None, error_repr).
|
||||
|
||||
가드(validate_delay_same_symbol_via_broker)에서 사용. 정확도 우선이라 실패 시 보수적 차단.
|
||||
"""
|
||||
try:
|
||||
executions = kc.get_order_executions(account_label, base_dt=now.strftime('%Y%m%d'))
|
||||
except Exception as e:
|
||||
return None, repr(e)
|
||||
return executions, None
|
||||
|
||||
|
||||
def collect_market_data(account_label: str, symbol: str, side: str, qty: int) -> dict:
|
||||
now = datetime.now(KST)
|
||||
quote = _safe_quote(symbol, account_label)
|
||||
orderbook = _safe_orderbook(symbol, account_label)
|
||||
broker_executions, broker_query_error = _safe_broker_executions(account_label, now)
|
||||
try:
|
||||
stock_meta = kc.lookup_stock_meta(symbol)
|
||||
except Exception:
|
||||
stock_meta = None
|
||||
|
||||
md = {
|
||||
'now': now,
|
||||
'is_holiday': guards.is_today_holiday(now),
|
||||
'nxt_eligible': _nxt_eligible(symbol, quote),
|
||||
'current_price': quote.get('cur_price', 0),
|
||||
'prev_close': quote.get('prev_close', 0),
|
||||
'upper_limit': quote.get('upper_limit', 0),
|
||||
'lower_limit': quote.get('lower_limit', 0),
|
||||
'halt': quote.get('halt', False),
|
||||
'vi': False, # VI 실시간 감지는 별도 채널 필요. 보강 예정.
|
||||
'orderbook': orderbook,
|
||||
'broker_executions': broker_executions,
|
||||
'broker_query_error': broker_query_error,
|
||||
'stock_meta': stock_meta,
|
||||
}
|
||||
|
||||
if side == 'BUY':
|
||||
try:
|
||||
bal = kc.get_balance(account_label)
|
||||
md['balance_d2'] = _to_int(bal.get('d2_entra'))
|
||||
except Exception:
|
||||
md['balance_d2'] = 0
|
||||
else:
|
||||
try:
|
||||
positions = kc.get_positions(account_label)
|
||||
md['position_qty'] = next(
|
||||
(_to_int(p.get('trde_able_qty') or p.get('qty') or p.get('hold_qty') or p.get('rmnd_qty'))
|
||||
for p in positions
|
||||
if (p.get('code') == symbol) or (p.get('symbol') == symbol) or (p.get('stk_cd') == symbol)),
|
||||
0,
|
||||
)
|
||||
except Exception:
|
||||
md['position_qty'] = 0
|
||||
|
||||
return md
|
||||
@@ -0,0 +1,24 @@
|
||||
"""launchd 진입점 — 만료된 활성 카드 정리 + 텔레그램 알림.
|
||||
|
||||
10초 간격으로 호출 (StartInterval=10). 활성 카드가 없거나 만료되지 않았으면 즉시 종료.
|
||||
PinStore 가 파일 기반이므로 별도 프로세스에서도 동일 활성 카드를 본다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from . import handler
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
handler._sweep_expired_and_notify()
|
||||
except Exception as e:
|
||||
print(f'[expiry_watcher] {type(e).__name__}: {e}', file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,457 @@
|
||||
"""주문 접수 후 체결·취소 추적 — on-demand 데몬 패턴.
|
||||
|
||||
알바 측:
|
||||
watch(...) [fill kind] / watch_cancel(...) [cancel kind]
|
||||
→ 큐 파일(state/fill_pending.jsonl)에 entry append + 데몬 ensure_running.
|
||||
|
||||
데몬 측 (orders/fill_watcher_daemon.py main):
|
||||
큐 파일 읽어 _FillWatcher._tracked 에 동기화
|
||||
→ kt00007 폴링(체결 추적) + ka10075 폴링(취소 추적, cancel watch 존재 시만)
|
||||
→ 알림 → 추적 끝난 entry 큐에서 제거 → 큐 비면 자기 종료(sys.exit + PID 파일 삭제).
|
||||
|
||||
폴링 스케줄 (모든 주문 공통, 가장 어린 주문 경과 시간 기준):
|
||||
- 0~30초: 5초 간격
|
||||
- 30~120초: 10초 간격
|
||||
- 120~600초: 30초 간격
|
||||
- 600~1800초: 60초 간격
|
||||
- 1800초(30분) 경과 시 미체결/미확인 알림 1회 + 추적 종료
|
||||
|
||||
cancel kind: 원주문(orig_ord_no)이 ka10075 미체결 목록에서 사라지면 취소 확정.
|
||||
사용자가 직접 발주한 fill watch 가 동시에 있으면 mdfy_cncl 발생 시 '사후거절' 메시지를
|
||||
억제(취소 watch 가 확정 메시지 책임).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from . import card, ledger
|
||||
|
||||
UNFILLED_TIMEOUT_SECONDS = 1800 # 30분
|
||||
|
||||
|
||||
def _spawn_journal_collect() -> None:
|
||||
"""전량 체결(filled) 직후 trade_journal.collect 비동기 호출.
|
||||
scripts/는 _SCRIPTS_DIR(=parent) 기준 sys.path 추가 후 import.
|
||||
실패해도 fill 흐름·21:00 launchd 재적재에 영향 없음."""
|
||||
def _run():
|
||||
try:
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
import trade_journal as tj
|
||||
tj.collect(quiet=True)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[fill→journal] collect failed: {e}\n')
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
_ORDERS_DIR = Path(__file__).resolve().parent
|
||||
_SCRIPTS_DIR = _ORDERS_DIR.parent
|
||||
_WORKSPACE_ROOT = _SCRIPTS_DIR.parent
|
||||
_STATE_DIR = _WORKSPACE_ROOT / 'state'
|
||||
QUEUE_FILE = _STATE_DIR / 'fill_pending.jsonl'
|
||||
QUEUE_LOCK = _STATE_DIR / 'fill_pending.jsonl.lock'
|
||||
PID_FILE = _STATE_DIR / 'fill_watcher.pid'
|
||||
|
||||
|
||||
# ---------- Tracked entry ----------
|
||||
|
||||
@dataclass
|
||||
class Tracked:
|
||||
ord_no: str
|
||||
account: str
|
||||
side: str
|
||||
symbol: str
|
||||
symbol_name: str
|
||||
order_qty: int
|
||||
price: Optional[int]
|
||||
order_type: str
|
||||
card_id: str
|
||||
started_at: float
|
||||
last_cntr_qty: int = 0
|
||||
kind: str = 'fill' # 'fill' (체결추적) | 'cancel' (취소확정추적)
|
||||
orig_ord_no: Optional[str] = None # cancel kind 전용 — 취소 대상 원주문 번호
|
||||
|
||||
|
||||
# ---------- 큐 파일 IO (atomic, file-locked) ----------
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _file_lock(path: Path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp = open(path, 'w')
|
||||
try:
|
||||
fcntl.flock(fp, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fp, fcntl.LOCK_UN)
|
||||
finally:
|
||||
fp.close()
|
||||
|
||||
|
||||
def append_queue_entry(entry: dict) -> None:
|
||||
"""큐 파일에 한 줄 append (lock 보호)."""
|
||||
with _file_lock(QUEUE_LOCK):
|
||||
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with QUEUE_FILE.open('a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
||||
|
||||
|
||||
def read_queue() -> list[dict]:
|
||||
"""큐 파일을 읽어 entry 리스트 반환. 빈 줄/잘못된 JSON 은 스킵."""
|
||||
if not QUEUE_FILE.exists():
|
||||
return []
|
||||
with _file_lock(QUEUE_LOCK):
|
||||
out: list[dict] = []
|
||||
for line in QUEUE_FILE.read_text(encoding='utf-8').splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except ValueError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def persist_queue(entries: list[dict]) -> None:
|
||||
"""큐 파일 전체 다시 쓰기 (rewrite)."""
|
||||
with _file_lock(QUEUE_LOCK):
|
||||
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not entries:
|
||||
if QUEUE_FILE.exists():
|
||||
try:
|
||||
QUEUE_FILE.unlink()
|
||||
except OSError:
|
||||
QUEUE_FILE.write_text('', encoding='utf-8')
|
||||
return
|
||||
body = '\n'.join(json.dumps(e, ensure_ascii=False) for e in entries) + '\n'
|
||||
tmp = QUEUE_FILE.with_suffix(QUEUE_FILE.suffix + '.tmp')
|
||||
tmp.write_text(body, encoding='utf-8')
|
||||
os.replace(tmp, QUEUE_FILE)
|
||||
|
||||
|
||||
# ---------- PID 파일 / 데몬 기동 ----------
|
||||
|
||||
def is_daemon_alive() -> bool:
|
||||
"""PID 파일 기반 데몬 생존 확인. stale 자동 검출."""
|
||||
if not PID_FILE.exists():
|
||||
return False
|
||||
try:
|
||||
pid = int(PID_FILE.read_text().strip())
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
# 다른 사용자 PID 와 충돌 — 매우 드묾. 살아있다고 보수적 가정.
|
||||
return True
|
||||
|
||||
|
||||
def ensure_daemon_running() -> None:
|
||||
"""데몬 살아있으면 패스, 죽었으면 fork. 두 알바 동시 호출에도 PID 파일 lock 으로 한 데몬만 살아남음."""
|
||||
if is_daemon_alive():
|
||||
return
|
||||
# 데몬 자체가 PID 파일 작성·정리 — 알바는 fork 만.
|
||||
subprocess.Popen(
|
||||
[sys.executable, '-m', 'orders.fill_watcher_daemon'],
|
||||
cwd=str(_SCRIPTS_DIR),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------- 데몬 entry — _FillWatcher 가 사용 ----------
|
||||
|
||||
class _FillWatcher:
|
||||
"""데몬 프로세스 안에서 사용되는 추적 워커. in-memory _tracked + 텔레그램·키움 콜백."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.RLock()
|
||||
self._tracked: dict[str, Tracked] = {}
|
||||
self._send: Callable[[str], bool] = lambda msg: True
|
||||
self._fetch_executions: Callable[[str], list[dict]] = lambda account: []
|
||||
self._fetch_open_orders: Callable[[str], list[dict]] = lambda account: []
|
||||
|
||||
def configure(self, send_func, fetch_executions, fetch_open_orders=None) -> None:
|
||||
with self._lock:
|
||||
self._send = send_func
|
||||
self._fetch_executions = fetch_executions
|
||||
if fetch_open_orders is not None:
|
||||
self._fetch_open_orders = fetch_open_orders
|
||||
|
||||
def sync_from_queue(self, entries: list[dict]) -> None:
|
||||
"""큐 파일 entry 리스트로 _tracked 동기화. 큐에 있고 _tracked 에 없으면 추가, 큐에서 사라진 ord_no 는 _tracked 에서도 제거."""
|
||||
with self._lock:
|
||||
queue_ord_nos = {e['ord_no'] for e in entries}
|
||||
# 추가
|
||||
for e in entries:
|
||||
ord_no = e['ord_no']
|
||||
if ord_no not in self._tracked:
|
||||
self._tracked[ord_no] = Tracked(
|
||||
ord_no=ord_no, account=e['account'], side=e['side'],
|
||||
symbol=e['symbol'], symbol_name=e['symbol_name'],
|
||||
order_qty=int(e['order_qty']),
|
||||
price=e.get('price'),
|
||||
order_type=e['order_type'], card_id=e['card_id'],
|
||||
started_at=float(e['started_at']),
|
||||
last_cntr_qty=int(e.get('last_cntr_qty', 0)),
|
||||
kind=e.get('kind', 'fill'),
|
||||
orig_ord_no=e.get('orig_ord_no'),
|
||||
)
|
||||
else:
|
||||
# 이미 있으면 last_cntr_qty 만 동기화 (큐가 진실 소스)
|
||||
self._tracked[ord_no].last_cntr_qty = int(e.get('last_cntr_qty', 0))
|
||||
# 제거
|
||||
for ord_no in list(self._tracked.keys()):
|
||||
if ord_no not in queue_ord_nos:
|
||||
self._tracked.pop(ord_no, None)
|
||||
|
||||
def snapshot_entries(self) -> list[dict]:
|
||||
"""현재 _tracked 를 큐 파일에 쓸 수 있는 entry 리스트로 직렬화."""
|
||||
with self._lock:
|
||||
return [asdict(t) for t in self._tracked.values()]
|
||||
|
||||
def _next_sleep_seconds(self) -> int:
|
||||
with self._lock:
|
||||
if not self._tracked:
|
||||
return 5
|
||||
now = time.time()
|
||||
min_elapsed = min(now - t.started_at for t in self._tracked.values())
|
||||
if min_elapsed < 30:
|
||||
return 5
|
||||
if min_elapsed < 120:
|
||||
return 10
|
||||
if min_elapsed < 600:
|
||||
return 30
|
||||
return 60
|
||||
|
||||
def _poll_once(self) -> None:
|
||||
with self._lock:
|
||||
fill_accounts = sorted({t.account for t in self._tracked.values()
|
||||
if t.kind == 'fill'})
|
||||
cancel_accounts = sorted({t.account for t in self._tracked.values()
|
||||
if t.kind == 'cancel'})
|
||||
tracked_snapshot = dict(self._tracked)
|
||||
|
||||
rows_by_account: dict[str, list[dict]] = {}
|
||||
for account in fill_accounts:
|
||||
try:
|
||||
rows_by_account[account] = self._fetch_executions(account) or []
|
||||
except Exception as e:
|
||||
ledger.append('rejected', {'reason': 'FILL_WATCHER_FETCH_ERROR',
|
||||
'account': account, 'message': repr(e)})
|
||||
|
||||
open_orders_by_account: dict[str, list[dict]] = {}
|
||||
for account in cancel_accounts:
|
||||
try:
|
||||
open_orders_by_account[account] = self._fetch_open_orders(account) or []
|
||||
except Exception as e:
|
||||
ledger.append('rejected', {'reason': 'CANCEL_WATCHER_FETCH_ERROR',
|
||||
'account': account, 'message': repr(e)})
|
||||
|
||||
now = time.time()
|
||||
for ord_no, t in tracked_snapshot.items():
|
||||
elapsed = now - t.started_at
|
||||
if t.kind == 'cancel':
|
||||
# 원주문이 미체결 목록에서 사라지면 취소 확정
|
||||
open_rows = open_orders_by_account.get(t.account)
|
||||
if open_rows is None:
|
||||
# fetch 실패 시 다음 폴링으로 미룸
|
||||
continue
|
||||
still_open = any((r.get('ord_no') or '').strip() == t.orig_ord_no
|
||||
for r in open_rows)
|
||||
if not still_open:
|
||||
self._handle_cancel_confirmed(t)
|
||||
elif elapsed >= UNFILLED_TIMEOUT_SECONDS:
|
||||
self._handle_cancel_timeout(t)
|
||||
continue
|
||||
# fill kind (기본)
|
||||
rows = rows_by_account.get(t.account, [])
|
||||
row = next((r for r in rows if (r.get('ord_no') or '').strip() == ord_no), None)
|
||||
if row is not None:
|
||||
self._handle_row(t, row)
|
||||
elif elapsed >= UNFILLED_TIMEOUT_SECONDS:
|
||||
self._handle_timeout(t)
|
||||
|
||||
def _handle_row(self, t: Tracked, row: dict) -> None:
|
||||
cntr_qty = int(row.get('cntr_qty') or 0)
|
||||
cntr_uv = int(row.get('cntr_uv') or 0)
|
||||
mdfy_cncl = (row.get('mdfy_cncl') or '').strip()
|
||||
|
||||
if mdfy_cncl:
|
||||
# 사용자가 cancel_open_order 로 발주한 취소가 잡힌 거면, cancel watch 가
|
||||
# 확정 메시지를 보낸다 — 여기서는 사후거절 알림 억제 + 조용히 fill watch 해제.
|
||||
with self._lock:
|
||||
user_initiated = any(
|
||||
x.kind == 'cancel' and x.orig_ord_no == t.ord_no
|
||||
for x in self._tracked.values()
|
||||
)
|
||||
self._tracked.pop(t.ord_no, None)
|
||||
if user_initiated:
|
||||
ledger.append('canceled', {'card_id': t.card_id, 'ord_no': t.ord_no,
|
||||
'account': t.account, 'symbol': t.symbol,
|
||||
'reason': 'USER_CANCEL_VIA_CANCEL_ORDER'})
|
||||
return
|
||||
ledger.append('failed', {'card_id': t.card_id, 'ord_no': t.ord_no,
|
||||
'account': t.account, 'symbol': t.symbol,
|
||||
'reason': 'BROKER_POST_REJECT',
|
||||
'mdfy_cncl': mdfy_cncl})
|
||||
self._send(card.format_broker_post_reject(t.card_id, t.side, t.symbol_name,
|
||||
t.ord_no, mdfy_cncl))
|
||||
return
|
||||
|
||||
if cntr_qty > t.last_cntr_qty:
|
||||
new_fill = cntr_qty - t.last_cntr_qty
|
||||
t.last_cntr_qty = cntr_qty
|
||||
if cntr_qty >= t.order_qty:
|
||||
with self._lock:
|
||||
self._tracked.pop(t.ord_no, None)
|
||||
ledger.append('filled', {'card_id': t.card_id, 'ord_no': t.ord_no,
|
||||
'account': t.account, 'symbol': t.symbol,
|
||||
'qty': cntr_qty, 'price': cntr_uv})
|
||||
self._send(card.format_filled(t.card_id, t.side, t.symbol_name,
|
||||
cntr_qty, cntr_uv, t.ord_no))
|
||||
# 전량 체결 → 자산웹 거래내역 즉시 갱신 (별도 스레드, 추적 블로킹 X)
|
||||
_spawn_journal_collect()
|
||||
else:
|
||||
ledger.append('partial', {'card_id': t.card_id, 'ord_no': t.ord_no,
|
||||
'account': t.account, 'symbol': t.symbol,
|
||||
'cntr_qty': cntr_qty, 'order_qty': t.order_qty,
|
||||
'price': cntr_uv, 'new_fill': new_fill})
|
||||
self._send(card.format_partial(t.card_id, t.side, t.symbol_name,
|
||||
cntr_qty, t.order_qty, cntr_uv, t.ord_no))
|
||||
|
||||
def _handle_timeout(self, t: Tracked) -> None:
|
||||
with self._lock:
|
||||
self._tracked.pop(t.ord_no, None)
|
||||
ledger.append('expired', {'card_id': t.card_id, 'ord_no': t.ord_no,
|
||||
'account': t.account, 'symbol': t.symbol,
|
||||
'reason': 'FILL_WATCH_TIMEOUT',
|
||||
'order_qty': t.order_qty,
|
||||
'last_cntr_qty': t.last_cntr_qty})
|
||||
self._send(card.format_unfilled_timeout(t.card_id, t.side, t.symbol_name,
|
||||
t.last_cntr_qty, t.order_qty, t.ord_no))
|
||||
|
||||
def _handle_cancel_confirmed(self, t: Tracked) -> None:
|
||||
with self._lock:
|
||||
self._tracked.pop(t.ord_no, None)
|
||||
if t.orig_ord_no:
|
||||
self._tracked.pop(str(t.orig_ord_no), None)
|
||||
ledger.append('cancel_confirmed', {'card_id': t.card_id,
|
||||
'new_ord_no': t.ord_no,
|
||||
'orig_ord_no': t.orig_ord_no,
|
||||
'account': t.account, 'symbol': t.symbol,
|
||||
'cancel_qty': t.order_qty})
|
||||
self._send(card.format_cancel_confirmed(t.side, t.symbol_name,
|
||||
t.orig_ord_no, t.ord_no, t.order_qty))
|
||||
|
||||
def _handle_cancel_timeout(self, t: Tracked) -> None:
|
||||
with self._lock:
|
||||
self._tracked.pop(t.ord_no, None)
|
||||
ledger.append('cancel_unconfirmed_timeout', {'card_id': t.card_id,
|
||||
'new_ord_no': t.ord_no,
|
||||
'orig_ord_no': t.orig_ord_no,
|
||||
'account': t.account,
|
||||
'symbol': t.symbol,
|
||||
'cancel_qty': t.order_qty})
|
||||
self._send(card.format_cancel_unconfirmed_timeout(t.side, t.symbol_name,
|
||||
t.orig_ord_no, t.ord_no))
|
||||
|
||||
|
||||
_watcher = _FillWatcher()
|
||||
|
||||
|
||||
# ---------- 외부 진입점 ----------
|
||||
|
||||
def configure(send_func, fetch_executions, fetch_open_orders=None):
|
||||
_watcher.configure(send_func, fetch_executions, fetch_open_orders)
|
||||
|
||||
|
||||
def watch(ord_no, account, side, symbol, symbol_name, order_qty, price, order_type, card_id):
|
||||
"""알바 측 진입점 — 큐에 append + 데몬 ensure_running.
|
||||
|
||||
in-memory 가 아니라 별도 데몬 프로세스가 추적. 호출 프로세스는 즉시 반환.
|
||||
"""
|
||||
if not ord_no:
|
||||
return
|
||||
entry = {
|
||||
'ord_no': str(ord_no), 'account': account, 'side': side,
|
||||
'symbol': symbol, 'symbol_name': symbol_name,
|
||||
'order_qty': int(order_qty), 'price': price,
|
||||
'order_type': order_type, 'card_id': card_id,
|
||||
'started_at': time.time(), 'last_cntr_qty': 0,
|
||||
'kind': 'fill',
|
||||
}
|
||||
# 큐에 이미 있는 ord_no 면 중복 append 방지
|
||||
existing = read_queue()
|
||||
if any(e.get('ord_no') == entry['ord_no'] for e in existing):
|
||||
ensure_daemon_running()
|
||||
return
|
||||
append_queue_entry(entry)
|
||||
ensure_daemon_running()
|
||||
|
||||
|
||||
def watch_cancel(new_ord_no, orig_ord_no, account, side, symbol, symbol_name,
|
||||
cancel_qty, card_id=None):
|
||||
"""취소 주문(kt10003) 접수 후 broker 확정 추적.
|
||||
|
||||
new_ord_no: kt10003 응답의 ord_no (취소 주문 자체 번호 — _tracked dict key)
|
||||
orig_ord_no: 취소 대상 원주문 ord_no — ka10075 폴링으로 사라지는지 감시
|
||||
cancel_qty: 취소 요청 수량 (cancel_qty=0 호출 시 발주 시점 unfilled_qty 전달)
|
||||
side/symbol/symbol_name: 원주문의 것 (텔레그램 메시지 가독성용)
|
||||
"""
|
||||
if not new_ord_no or not orig_ord_no:
|
||||
return
|
||||
entry = {
|
||||
'ord_no': str(new_ord_no), 'account': account, 'side': side,
|
||||
'symbol': symbol, 'symbol_name': symbol_name,
|
||||
'order_qty': int(cancel_qty), 'price': None,
|
||||
'order_type': 'CANCEL', 'card_id': card_id or '',
|
||||
'started_at': time.time(), 'last_cntr_qty': 0,
|
||||
'kind': 'cancel', 'orig_ord_no': str(orig_ord_no),
|
||||
}
|
||||
existing = read_queue()
|
||||
if any(e.get('ord_no') == entry['ord_no'] for e in existing):
|
||||
ensure_daemon_running()
|
||||
return
|
||||
append_queue_entry(entry)
|
||||
ensure_daemon_running()
|
||||
|
||||
|
||||
# ---------- 테스트 헬퍼 ----------
|
||||
|
||||
def _reset_for_test():
|
||||
with _watcher._lock:
|
||||
_watcher._tracked.clear()
|
||||
if QUEUE_FILE.exists():
|
||||
try:
|
||||
QUEUE_FILE.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
if PID_FILE.exists():
|
||||
try:
|
||||
PID_FILE.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _peek_for_test():
|
||||
with _watcher._lock:
|
||||
return dict(_watcher._tracked)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""체결 추적 on-demand 데몬 entry point.
|
||||
|
||||
알바(handler.submit_with_pin)가 fork 한 자식 프로세스에서 실행됨.
|
||||
|
||||
수명:
|
||||
1. PID 파일 atomic 작성 (이미 살아있는 데몬 있으면 즉시 종료)
|
||||
2. 큐 파일 → _FillWatcher._tracked 동기화
|
||||
3. kt00007 폴링 → 체결/거절/타임아웃 알림 → 추적 종료된 ord_no 큐에서 제거
|
||||
4. 큐 비면 PID 파일 삭제 + sys.exit(0)
|
||||
5. 다음 매매 시 알바가 다시 fork
|
||||
|
||||
CLI 직접 호출은 하지 않음. 운영은 알바의 ensure_daemon_running() 만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
# 패키지 import 경로 보장 — 알바가 cwd=scripts/ 로 띄움
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from orders import fill_watcher # noqa: E402
|
||||
|
||||
LOG_FILE = fill_watcher._STATE_DIR / 'fill_watcher.log'
|
||||
PID_LOCK = fill_watcher._STATE_DIR / 'fill_watcher.pid.lock'
|
||||
|
||||
_shutdown = False
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
ts = time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime())
|
||||
try:
|
||||
with LOG_FILE.open('a', encoding='utf-8') as f:
|
||||
f.write(f'[{ts}] {msg}\n')
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _pid_lock():
|
||||
PID_LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp = open(PID_LOCK, 'w')
|
||||
try:
|
||||
fcntl.flock(fp, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fp, fcntl.LOCK_UN)
|
||||
finally:
|
||||
fp.close()
|
||||
|
||||
|
||||
def _claim_pid() -> bool:
|
||||
"""PID 파일 atomic 작성. 이미 살아있는 데몬 있으면 False."""
|
||||
with _pid_lock():
|
||||
if fill_watcher.is_daemon_alive():
|
||||
return False
|
||||
fill_watcher.PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
fill_watcher.PID_FILE.write_text(str(os.getpid()), encoding='utf-8')
|
||||
return True
|
||||
|
||||
|
||||
def _release_pid() -> None:
|
||||
try:
|
||||
if fill_watcher.PID_FILE.exists():
|
||||
try:
|
||||
pid = int(fill_watcher.PID_FILE.read_text().strip())
|
||||
except (ValueError, OSError):
|
||||
pid = -1
|
||||
if pid == os.getpid():
|
||||
fill_watcher.PID_FILE.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _handle_sigterm(signum, frame):
|
||||
global _shutdown
|
||||
_shutdown = True
|
||||
|
||||
|
||||
def _build_telegram_sender():
|
||||
# 순환 import 방지 — 데몬에서만 import
|
||||
from orders import handler
|
||||
return lambda msg: handler.send_telegram(msg, parse_mode=None)
|
||||
|
||||
|
||||
def _build_kiwoom_fetcher():
|
||||
import kiwoom_client as kc
|
||||
return lambda account: kc.get_order_executions(account)
|
||||
|
||||
|
||||
def _build_kiwoom_open_orders_fetcher():
|
||||
import kiwoom_client as kc
|
||||
return lambda account: kc.get_open_orders(account)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not _claim_pid():
|
||||
_log('skip — daemon already running')
|
||||
return 0
|
||||
|
||||
signal.signal(signal.SIGTERM, _handle_sigterm)
|
||||
signal.signal(signal.SIGINT, _handle_sigterm)
|
||||
|
||||
fill_watcher.configure(
|
||||
send_func=_build_telegram_sender(),
|
||||
fetch_executions=_build_kiwoom_fetcher(),
|
||||
fetch_open_orders=_build_kiwoom_open_orders_fetcher(),
|
||||
)
|
||||
_log(f'started pid={os.getpid()}')
|
||||
|
||||
try:
|
||||
while not _shutdown:
|
||||
queue = fill_watcher.read_queue()
|
||||
if not queue:
|
||||
_log('queue empty — exiting')
|
||||
return 0
|
||||
fill_watcher._watcher.sync_from_queue(queue)
|
||||
try:
|
||||
fill_watcher._watcher._poll_once()
|
||||
except Exception:
|
||||
_log('poll error:\n' + traceback.format_exc())
|
||||
# 추적 종료된 entry 큐에서 제거
|
||||
fill_watcher.persist_queue(fill_watcher._watcher.snapshot_entries())
|
||||
time.sleep(fill_watcher._watcher._next_sleep_seconds())
|
||||
_log('shutdown signal — exiting')
|
||||
return 0
|
||||
finally:
|
||||
_release_pid()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,549 @@
|
||||
"""
|
||||
주문 검증 가드 (순수 함수).
|
||||
|
||||
키움 API 호출은 datasource 가 담당. guards 는 (request, market_data) 두 dict 만 받아
|
||||
결정한다. 단위테스트가 모든 분기를 mock 데이터로 검증한다.
|
||||
|
||||
검증 순서 (validate_request):
|
||||
1. 계좌 화이트리스트
|
||||
2. side 유효성 (BUY/SELL)
|
||||
3. 거래시간 + 휴장일 + NXT 매트릭스
|
||||
4. 거래정지 / VI
|
||||
5. 전체 거래 60초 딜레이 (ledger 조회)
|
||||
6. 동일 종목 3분 딜레이 (ledger 조회)
|
||||
7. 동일 종목 3분 딜레이 (키움 진실 소스 — kt00007). NETWORK 사각·키움앱 직접 매매까지 포함
|
||||
8. ±30% 가격 가드 (지정가만)
|
||||
9. 잔고(매수) / 보유수량(매도) 사전조회
|
||||
|
||||
라우팅 결정 (determine_routing) 은 검증 통과 후 호출자가 별도로 부른다.
|
||||
시장가 슬리피지·평균체결가 추정은 estimate_market_fill 로 카드에 표시.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time as dtime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from . import ledger
|
||||
|
||||
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
|
||||
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def _limits() -> dict:
|
||||
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
ok: bool
|
||||
code: str
|
||||
message: str
|
||||
|
||||
@classmethod
|
||||
def OK(cls, code: str = 'OK', message: str = '') -> 'Result':
|
||||
return cls(True, code, message)
|
||||
|
||||
@classmethod
|
||||
def REJECT(cls, code: str, message: str) -> 'Result':
|
||||
return cls(False, code, message)
|
||||
|
||||
|
||||
# ---- 계좌 ----
|
||||
|
||||
def validate_account(account: str) -> Result:
|
||||
if account not in _limits()['accounts_whitelist']:
|
||||
return Result.REJECT('ACCOUNT_NOT_WHITELISTED', f'허용되지 않은 계좌: {account}')
|
||||
return Result.OK()
|
||||
|
||||
|
||||
def is_spouse_account(account: str) -> bool:
|
||||
return account in _limits()['spouse_accounts']
|
||||
|
||||
|
||||
# ---- 거래시간 + NXT 매트릭스 ----
|
||||
|
||||
def _parse_hms(s: str) -> dtime:
|
||||
parts = [int(x) for x in s.split(':')]
|
||||
while len(parts) < 3:
|
||||
parts.append(0)
|
||||
return dtime(parts[0], parts[1], parts[2])
|
||||
|
||||
|
||||
def session_at(now: datetime) -> str:
|
||||
th = _limits()['trading_hours']
|
||||
t = now.replace(tzinfo=None).time()
|
||||
if _parse_hms(th['nxt_pre_start']) <= t < _parse_hms(th['nxt_pre_end']):
|
||||
return 'NXT_PRE'
|
||||
if _parse_hms(th['krx_regular_start']) <= t < _parse_hms(th['krx_regular_end']):
|
||||
return 'KRX_NXT'
|
||||
if _parse_hms(th['krx_closing_auction_start']) <= t < _parse_hms(th['krx_closing_auction_end']):
|
||||
return 'KRX_CLOSE'
|
||||
if _parse_hms(th['nxt_after_start']) <= t < _parse_hms(th['nxt_after_end']):
|
||||
return 'NXT_AFTER'
|
||||
return 'CLOSED'
|
||||
|
||||
|
||||
def is_today_holiday(now: datetime) -> bool:
|
||||
th = _limits()['trading_hours']
|
||||
rel = th.get('holiday_state_file')
|
||||
if not rel:
|
||||
return False
|
||||
p = WORKSPACE_ROOT / rel
|
||||
if not p.exists():
|
||||
return False
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
today = now.strftime('%Y-%m-%d')
|
||||
holidays = data.get('holidays') if isinstance(data, dict) else data
|
||||
if not isinstance(holidays, list):
|
||||
return False
|
||||
for h in holidays:
|
||||
if isinstance(h, str) and h == today:
|
||||
return True
|
||||
if isinstance(h, dict) and h.get('date') == today:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_trading_hours(now: datetime, is_holiday: bool, nxt_eligible: bool) -> Result:
|
||||
if is_holiday:
|
||||
return Result.REJECT('HOLIDAY', '휴장일에는 매매 불가')
|
||||
sess = session_at(now)
|
||||
if sess == 'CLOSED':
|
||||
return Result.REJECT('OUTSIDE_HOURS', f'거래시간 외 ({now.strftime("%H:%M:%S")})')
|
||||
if sess in ('NXT_PRE', 'NXT_AFTER') and not nxt_eligible:
|
||||
return Result.REJECT('NXT_NOT_ELIGIBLE', '지금은 NXT 시간대인데 이 종목은 NXT 거래 불가')
|
||||
return Result.OK(code=sess)
|
||||
|
||||
|
||||
def determine_routing(now: datetime, nxt_eligible: bool, force: Optional[str]) -> str:
|
||||
routing = _limits()['routing']
|
||||
suffix_map = {
|
||||
'AL': routing['suffix_AL'],
|
||||
'NX': routing['suffix_NX'],
|
||||
'KRX': routing['suffix_KRX'],
|
||||
}
|
||||
if force:
|
||||
if force not in routing['force_options']:
|
||||
raise ValueError(f'unknown routing force: {force}')
|
||||
return suffix_map[force]
|
||||
sess = session_at(now)
|
||||
if sess in ('NXT_PRE', 'NXT_AFTER'):
|
||||
return routing['suffix_NX']
|
||||
if sess == 'KRX_CLOSE':
|
||||
return routing['suffix_KRX']
|
||||
if sess == 'KRX_NXT':
|
||||
return routing['suffix_AL'] if nxt_eligible else routing['suffix_KRX']
|
||||
raise ValueError('CLOSED session has no valid routing')
|
||||
|
||||
|
||||
# ---- 가격 가드 (±30% 상한가/하한가) ----
|
||||
|
||||
def validate_price_band(side: str, price: int, upper_limit: int, lower_limit: int) -> Result:
|
||||
if price > upper_limit:
|
||||
return Result.REJECT('PRICE_ABOVE_UPPER', f'지정가 {price:,}원 > 상한가 {upper_limit:,}원')
|
||||
if price < lower_limit:
|
||||
return Result.REJECT('PRICE_BELOW_LOWER', f'지정가 {price:,}원 < 하한가 {lower_limit:,}원')
|
||||
return Result.OK()
|
||||
|
||||
|
||||
# ---- 잔고 / 보유 ----
|
||||
|
||||
def validate_balance_for_buy(qty: int, price_estimate: int, balance_d2: int,
|
||||
basis: Optional[str] = None) -> Result:
|
||||
needed = qty * price_estimate
|
||||
if balance_d2 < needed:
|
||||
basis_label = f' ({basis} 기준)' if basis else ''
|
||||
return Result.REJECT('INSUFFICIENT_BALANCE',
|
||||
f'예수금 부족: 필요 {needed:,}원{basis_label} / 가용 {balance_d2:,}원')
|
||||
return Result.OK()
|
||||
|
||||
|
||||
def validate_position_for_sell(qty: int, position_qty: int) -> Result:
|
||||
if position_qty < qty:
|
||||
return Result.REJECT('INSUFFICIENT_POSITION',
|
||||
f'보유 부족: 매도 {qty}주 / 보유 {position_qty}주')
|
||||
return Result.OK()
|
||||
|
||||
|
||||
# ---- 시장가 세션 제한 (NXT 단독·KRX 단일가에선 시장가 불가) ----
|
||||
|
||||
def validate_market_order_session(now: datetime, order_type: str) -> Result:
|
||||
if order_type != 'MARKET':
|
||||
return Result.OK()
|
||||
sess = session_at(now)
|
||||
if sess in ('NXT_PRE', 'NXT_AFTER'):
|
||||
return Result.REJECT('MARKET_NOT_ALLOWED_IN_NXT',
|
||||
'NXT 시간대에는 시장가 불가 — 지정가만 가능. "지금 바로" 또는 가격 명시로 다시 시도.')
|
||||
if sess == 'KRX_CLOSE':
|
||||
return Result.REJECT('MARKET_NOT_ALLOWED_IN_AUCTION',
|
||||
'단일가 동시호가 시간대에는 시장가 불가 — 지정가만 가능.')
|
||||
return Result.OK()
|
||||
|
||||
|
||||
# ---- 정지 / VI ----
|
||||
|
||||
def validate_halt_vi(halt: bool, vi: bool) -> Result:
|
||||
if halt:
|
||||
return Result.REJECT('TRADING_HALT', '거래정지 종목')
|
||||
if vi:
|
||||
return Result.REJECT('VI', 'VI 발동 종목')
|
||||
return Result.OK()
|
||||
|
||||
|
||||
# ---- 종목 상태 (ka10099 캐시 기반) ----
|
||||
|
||||
# orderWarning 코드 (ka10099 명세):
|
||||
# 0: 해당없음, 1: ETF투자주의요망, 2: 정리매매, 3: 단기과열, 4: 투자위험, 5: 투자경과
|
||||
_ORDER_WARNING_REJECT = {'2', '4'} # 정리매매·투자위험 → 사전 거부
|
||||
_ORDER_WARNING_LABELS = {
|
||||
'1': 'ETF투자주의요망',
|
||||
'2': '정리매매',
|
||||
'3': '단기과열',
|
||||
'4': '투자위험',
|
||||
'5': '투자경과',
|
||||
}
|
||||
_STATE_REJECT_KEYWORDS = ('거래정지', '정리매매') # state 텍스트 부분 매치 → 거부
|
||||
_STATE_WARN_KEYWORDS = ('관리종목',) # state 텍스트 부분 매치 → 경고
|
||||
|
||||
|
||||
def evaluate_stock_state(stock_meta: dict | None) -> dict:
|
||||
"""ka10099 캐시 메타로 종목 상태 평가.
|
||||
|
||||
정책 (등급별 차등 — 2026-05-07 결정):
|
||||
- 거부(STOCK_STATE_BLOCKED): orderWarning ∈ {2 정리매매, 4 투자위험} 또는
|
||||
state 에 '거래정지'·'정리매매' 키워드 포함
|
||||
- 경고(warning): orderWarning ∈ {1 ETF주의, 3 단기과열, 5 투자경과} 또는
|
||||
state 에 '관리종목' 포함
|
||||
|
||||
Returns: {'result': Result, 'warning': str | None, 'state': str, 'order_warning': str}
|
||||
"""
|
||||
state = (stock_meta or {}).get('state', '') or ''
|
||||
ow = str((stock_meta or {}).get('order_warning', '0') or '0').strip()
|
||||
|
||||
# 거부 우선
|
||||
if ow in _ORDER_WARNING_REJECT:
|
||||
label = _ORDER_WARNING_LABELS.get(ow, f'코드 {ow}')
|
||||
return {
|
||||
'result': Result.REJECT('STOCK_STATE_BLOCKED', f'{label} 종목 — 매매 차단'),
|
||||
'warning': None, 'state': state, 'order_warning': ow,
|
||||
}
|
||||
for kw in _STATE_REJECT_KEYWORDS:
|
||||
if kw in state:
|
||||
return {
|
||||
'result': Result.REJECT('STOCK_STATE_BLOCKED', f'{kw} 종목 — 매매 차단'),
|
||||
'warning': None, 'state': state, 'order_warning': ow,
|
||||
}
|
||||
|
||||
# 경고
|
||||
warning = None
|
||||
if ow in _ORDER_WARNING_LABELS: # 1, 3, 5 (2/4는 위에서 이미 거부됨)
|
||||
warning = f'⚠️ 키움 경고: {_ORDER_WARNING_LABELS[ow]} (orderWarning={ow})'
|
||||
else:
|
||||
for kw in _STATE_WARN_KEYWORDS:
|
||||
if kw in state:
|
||||
warning = f'⚠️ 키움 경고: {kw} (state="{state}")'
|
||||
break
|
||||
|
||||
return {'result': Result.OK(), 'warning': warning, 'state': state, 'order_warning': ow}
|
||||
|
||||
|
||||
# ---- 딜레이 (ledger 조회) ----
|
||||
|
||||
def validate_delay_between_orders(now: datetime) -> Result:
|
||||
# 접수(submitted) 시점만 카운트 — filled/partial은 키움 처리 결과 (사용자 명령 시점 아님)
|
||||
last = ledger.last_terminal_event(events=('submitted',))
|
||||
if not last:
|
||||
return Result.OK()
|
||||
last_ts = datetime.fromisoformat(last['ts'])
|
||||
elapsed = (now - last_ts).total_seconds()
|
||||
cooldown = _limits()['delays']['between_orders_seconds']
|
||||
if elapsed < cooldown:
|
||||
return Result.REJECT('COOLDOWN_GLOBAL', f'마지막 거래 후 {int(cooldown - elapsed)}초 남음')
|
||||
return Result.OK()
|
||||
|
||||
|
||||
def validate_delay_same_symbol(now: datetime, account: str, symbol: str) -> Result:
|
||||
# 접수(submitted) 시점만 카운트 — filled/partial은 키움 처리 결과라 사용자 명령 시점 아님,
|
||||
# 한 번 접수 후 N초간 같은 종목 재시도만 차단하면 충분.
|
||||
last = ledger.last_event_for_symbol(account, symbol, events=('submitted',))
|
||||
if not last:
|
||||
return Result.OK()
|
||||
last_ts = datetime.fromisoformat(last['ts'])
|
||||
elapsed = (now - last_ts).total_seconds()
|
||||
cooldown = _limits()['delays']['same_symbol_seconds']
|
||||
if elapsed < cooldown:
|
||||
remaining = int(cooldown - elapsed)
|
||||
m, s = divmod(remaining, 60)
|
||||
return Result.REJECT('COOLDOWN_SAME_SYMBOL',
|
||||
f'[{symbol}] 마지막 체결 후 {m}분 {s}초 남음')
|
||||
return Result.OK()
|
||||
|
||||
|
||||
def validate_delay_same_symbol_via_broker(now: datetime, symbol: str,
|
||||
broker_executions: Optional[list],
|
||||
broker_query_error: Optional[str]) -> Result:
|
||||
"""키움 진실 소스(kt00007) 기준 동일 종목 딜레이.
|
||||
|
||||
NETWORK 사각(우리 ledger 'failed' 인데 실은 키움에 들어감) 과
|
||||
사용자 키움앱 직접 매매까지 포함한 검증. ledger 가드 통과 후 추가로 호출.
|
||||
|
||||
조회 자체 실패 시 보수적 차단 (BROKER_QUERY_FAILED) — 정확도 우선.
|
||||
"""
|
||||
if broker_query_error is not None:
|
||||
return Result.REJECT('BROKER_QUERY_FAILED',
|
||||
f'키움 체결조회 실패로 안전 차단: {broker_query_error}')
|
||||
if not broker_executions:
|
||||
return Result.OK()
|
||||
cooldown = _limits()['delays']['same_symbol_seconds']
|
||||
latest_ts: Optional[datetime] = None
|
||||
latest_src = ''
|
||||
for ex in broker_executions:
|
||||
if ex.get('code') != symbol:
|
||||
continue
|
||||
# 접수(ord_tm) 시점 카운트 — cntr_qty 무관 (체결 여부와 별개로 접수 자체가 사용자 명령 시점)
|
||||
ord_tm = (ex.get('ord_tm') or '').strip()
|
||||
if len(ord_tm) < 5:
|
||||
continue
|
||||
try:
|
||||
t = datetime.strptime(ord_tm, '%H:%M:%S').time()
|
||||
except ValueError:
|
||||
continue
|
||||
ts = datetime.combine(now.date(), t).replace(tzinfo=KST)
|
||||
if latest_ts is None or ts > latest_ts:
|
||||
latest_ts = ts
|
||||
latest_src = ex.get('comm_src', '') or ''
|
||||
if latest_ts is None:
|
||||
return Result.OK()
|
||||
elapsed = (now - latest_ts).total_seconds()
|
||||
if elapsed < cooldown:
|
||||
remaining = int(cooldown - elapsed)
|
||||
m, s = divmod(remaining, 60)
|
||||
src_hint = f' [출처: {latest_src}]' if latest_src else ''
|
||||
return Result.REJECT('COOLDOWN_SAME_SYMBOL_BROKER',
|
||||
f'[{symbol}] 키움 기준 마지막 매매({latest_ts.strftime("%H:%M:%S")}) 후 '
|
||||
f'{m}분 {s}초 남음{src_hint}')
|
||||
return Result.OK()
|
||||
|
||||
|
||||
# ---- 자연어 시장가 분류 + 호가단위 ----
|
||||
|
||||
def classify_order_intent(text: str) -> str:
|
||||
"""레이 파싱 보조. 'MARKET' / 'AGGRESSIVE_LIMIT' / 'LIMIT'.
|
||||
|
||||
명시 키워드만 체크. 모호하면 안전한 LIMIT.
|
||||
"""
|
||||
cfg = _limits()['market_order']
|
||||
lower = text.lower() if text else ''
|
||||
for kw in cfg['natural_language_market']:
|
||||
if kw.lower() in lower:
|
||||
return 'MARKET'
|
||||
for kw in cfg['natural_language_aggressive_limit']:
|
||||
if kw.lower() in lower:
|
||||
return 'AGGRESSIVE_LIMIT'
|
||||
return 'LIMIT'
|
||||
|
||||
|
||||
def tick_size(price: int) -> int:
|
||||
"""KRX 표준 호가단위 (2023 개편 후, NXT 도 동일 적용)."""
|
||||
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 aggressive_limit_price(side: str, orderbook: Optional[dict],
|
||||
ticks: Optional[int] = None,
|
||||
fallback_price: Optional[int] = None) -> dict:
|
||||
"""공격적 지정가 산정.
|
||||
|
||||
호가창 우선, 없으면 fallback_price (ka10001 현재가) 로 대체.
|
||||
Returns: {'ok': bool, 'price': int, 'source': 'orderbook'|'fallback', 'ref_price': int}
|
||||
또는 {'ok': False, 'code': str, 'message': str}
|
||||
"""
|
||||
if ticks is None:
|
||||
ticks = _limits()['market_order']['aggressive_limit_ticks']
|
||||
if orderbook:
|
||||
levels = orderbook.get('asks' if side == 'BUY' else 'bids') or []
|
||||
if levels:
|
||||
ref = int(levels[0]['price'])
|
||||
if ref > 0:
|
||||
if side == 'BUY':
|
||||
price = ref + ticks * tick_size(ref)
|
||||
else:
|
||||
price = ref - ticks * tick_size(ref)
|
||||
return {'ok': True, 'price': price, 'source': 'orderbook', 'ref_price': ref}
|
||||
# fallback — ka10001 현재가
|
||||
if fallback_price and fallback_price > 0:
|
||||
ref = int(fallback_price)
|
||||
if side == 'BUY':
|
||||
price = ref + ticks * tick_size(ref)
|
||||
else:
|
||||
price = ref - ticks * tick_size(ref)
|
||||
return {'ok': True, 'price': price, 'source': 'fallback', 'ref_price': ref}
|
||||
return {'ok': False, 'code': 'NO_ORDERBOOK',
|
||||
'message': '호가창·현재가 모두 조회 실패 — 공격적 지정가 산정 불가'}
|
||||
|
||||
|
||||
BUDGET_BUMP_MAX_REF_PRICE = 300_000
|
||||
|
||||
|
||||
def convert_budget_to_qty(side: str, budget: int, orderbook: Optional[dict],
|
||||
fallback_price: Optional[int] = None) -> dict:
|
||||
"""예산(원) → 정수 주식 수량 환산.
|
||||
|
||||
BUY: 매도1호가 기준 (시장가 매수 시 실제 체결 가능성 가장 높은 가격).
|
||||
SELL: 매수1호가 기준 (대칭 — 매도 회수 추정).
|
||||
호가창 비어있으면 fallback_price (ka10001 현재가) 로 대체.
|
||||
버림 (floor). 슬리피지 마진 0%.
|
||||
|
||||
BUY +1 정책: 1주 가격 ≤ 300,000원 이고 floor 잔액이 0보다 크면 qty+=1.
|
||||
예산을 살짝 초과해 1주 더 매수. SELL 은 항상 floor (보유수량 초과 매도 방지).
|
||||
|
||||
Returns:
|
||||
ok=True 시 {'ok': True, 'qty': int, 'ref_price': int, 'remainder': int,
|
||||
'source': 'orderbook'|'fallback', 'bumped': bool}
|
||||
remainder: 음수면 예산 초과액 (bumped=True 일 때만 음수 가능).
|
||||
ok=False 시 {'ok': False, 'code': str, 'message': str}
|
||||
"""
|
||||
if not isinstance(budget, int) or budget <= 0:
|
||||
return {'ok': False, 'code': 'BUDGET_INVALID',
|
||||
'message': f'예산은 양의 정수여야 합니다 (입력: {budget!r})'}
|
||||
ref_price = 0
|
||||
source = 'orderbook'
|
||||
if orderbook:
|
||||
levels = orderbook.get('asks' if side == 'BUY' else 'bids') or []
|
||||
if levels:
|
||||
cand = int(levels[0]['price'])
|
||||
if cand > 0:
|
||||
ref_price = cand
|
||||
if ref_price <= 0:
|
||||
if fallback_price and fallback_price > 0:
|
||||
ref_price = int(fallback_price)
|
||||
source = 'fallback'
|
||||
else:
|
||||
return {'ok': False, 'code': 'NO_ORDERBOOK',
|
||||
'message': '호가창·현재가 모두 조회 실패 — 금액 환산 불가'}
|
||||
qty = budget // ref_price
|
||||
remainder = budget - qty * ref_price
|
||||
bumped = False
|
||||
if (side == 'BUY' and qty > 0 and remainder > 0
|
||||
and ref_price <= BUDGET_BUMP_MAX_REF_PRICE):
|
||||
qty += 1
|
||||
remainder = budget - qty * ref_price # 음수 — 예산 초과액
|
||||
bumped = True
|
||||
if qty <= 0:
|
||||
ref_label_map = {
|
||||
('BUY', 'orderbook'): '매도1호가',
|
||||
('SELL', 'orderbook'): '매수1호가',
|
||||
('BUY', 'fallback'): '현재가',
|
||||
('SELL', 'fallback'): '현재가',
|
||||
}
|
||||
ref_label = ref_label_map[(side, source)]
|
||||
return {'ok': False, 'code': 'BUDGET_TOO_SMALL',
|
||||
'message': f'1주 가격({ref_price:,}원, {ref_label})이 예산({budget:,}원)보다 큽니다'}
|
||||
return {'ok': True, 'qty': qty, 'ref_price': ref_price,
|
||||
'remainder': remainder, 'source': source, 'bumped': bumped}
|
||||
|
||||
|
||||
def estimate_market_fill(side: str, qty: int, orderbook: dict, depth: Optional[int] = None) -> dict:
|
||||
"""시장가 평균체결가·슬리피지 추정. 호가창 부족분은 마지막 호가가 추정."""
|
||||
if depth is None:
|
||||
depth = _limits()['market_order']['show_orderbook_depth']
|
||||
levels = (orderbook['asks'] if side == 'BUY' else orderbook['bids'])[:depth]
|
||||
if not levels:
|
||||
return {'avg_fill': 0, 'total_won': 0, 'reference_price': 0, 'slippage_pct': 0.0}
|
||||
remaining = qty
|
||||
total_won = 0
|
||||
last_price = levels[0]['price']
|
||||
for lvl in levels:
|
||||
if remaining <= 0:
|
||||
break
|
||||
take = min(remaining, lvl['qty'])
|
||||
total_won += take * lvl['price']
|
||||
remaining -= take
|
||||
last_price = lvl['price']
|
||||
if remaining > 0:
|
||||
total_won += remaining * last_price
|
||||
avg = total_won // qty
|
||||
ref = levels[0]['price']
|
||||
slip = ((avg - ref) / ref * 100) if ref else 0.0
|
||||
if side == 'SELL':
|
||||
slip = -slip
|
||||
return {
|
||||
'avg_fill': avg,
|
||||
'total_won': total_won,
|
||||
'reference_price': ref,
|
||||
'slippage_pct': round(slip, 3),
|
||||
}
|
||||
|
||||
|
||||
# ---- 통합 ----
|
||||
|
||||
def validate_request(request: dict, market_data: dict) -> Result:
|
||||
r = validate_account(request['account'])
|
||||
if not r.ok:
|
||||
return r
|
||||
if request['side'] not in ('BUY', 'SELL'):
|
||||
return Result.REJECT('INVALID_SIDE', f'잘못된 방향: {request["side"]}')
|
||||
if request['order_type'] not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
|
||||
return Result.REJECT('INVALID_ORDER_TYPE', f'잘못된 주문방식: {request["order_type"]}')
|
||||
r = validate_trading_hours(market_data['now'], market_data.get('is_holiday', False),
|
||||
market_data.get('nxt_eligible', False))
|
||||
if not r.ok:
|
||||
return r
|
||||
r = validate_halt_vi(market_data.get('halt', False), market_data.get('vi', False))
|
||||
if not r.ok:
|
||||
return r
|
||||
r = validate_market_order_session(market_data['now'], request['order_type'])
|
||||
if not r.ok:
|
||||
return r
|
||||
r = validate_delay_between_orders(market_data['now'])
|
||||
if not r.ok:
|
||||
return r
|
||||
# same_symbol 가드는 between_orders_seconds 글로벌 가드로 통합됨 — 같은 종목 별도 필터 X.
|
||||
# 함수 자체는 보존 (limits.json 분리 설정 시 부활 가능).
|
||||
if request['order_type'] == 'LIMIT':
|
||||
r = validate_price_band(request['side'], request['price'],
|
||||
market_data['upper_limit'], market_data['lower_limit'])
|
||||
if not r.ok:
|
||||
return r
|
||||
if request['side'] == 'BUY':
|
||||
basis = None
|
||||
if request['order_type'] == 'MARKET':
|
||||
# 키움 시장가 매수 증거금은 상한가 × qty 기준. 호가창 평균이 아닌 상한가로 사전 차단.
|
||||
price_est = market_data.get('upper_limit', 0)
|
||||
basis = '상한가'
|
||||
elif request['order_type'] == 'AGGRESSIVE_LIMIT':
|
||||
agg_res = aggressive_limit_price('BUY', market_data.get('orderbook'),
|
||||
fallback_price=market_data.get('current_price'))
|
||||
if not agg_res['ok']:
|
||||
return Result.REJECT(agg_res['code'], agg_res['message'])
|
||||
price_est = agg_res['price']
|
||||
else:
|
||||
price_est = request['price']
|
||||
r = validate_balance_for_buy(request['qty'], price_est, market_data['balance_d2'], basis=basis)
|
||||
if not r.ok:
|
||||
return r
|
||||
else:
|
||||
r = validate_position_for_sell(request['qty'], market_data['position_qty'])
|
||||
if not r.ok:
|
||||
return r
|
||||
return Result.OK()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
키움 REST API 주문 호출 — 매수 kt10000 / 매도 kt10001 / 정정 kt10002 / 취소 kt10003.
|
||||
|
||||
* dry-run default. dry_run=False 명시 시에만 실주문.
|
||||
* 진입점 첫 줄에서 sidecar.guard_or_raise() 호출 강제.
|
||||
* 자동 재시도 금지 — 네트워크 에러 시 한 번만 시도하고 사람이 ord_no 조회로 재판단.
|
||||
* 멱등성: 동일 (계좌·종목·side·수량·가격) 60초 윈도우 해시로 중복 차단.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_PARENT = Path(__file__).resolve().parent.parent
|
||||
if str(_PARENT) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT))
|
||||
|
||||
import kiwoom_client as kc
|
||||
from . import ledger, sidecar
|
||||
|
||||
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
|
||||
|
||||
TR_BUY = 'kt10000'
|
||||
TR_SELL = 'kt10001'
|
||||
TR_MODIFY = 'kt10002'
|
||||
TR_CANCEL = 'kt10003'
|
||||
|
||||
# 매매구분 코드
|
||||
TRDE_TP_LIMIT = '0' # 보통가 (지정가)
|
||||
TRDE_TP_MARKET = '3' # 시장가
|
||||
|
||||
# 거래소 코드 (라우팅 suffix → API 거래소 구분)
|
||||
_EXCHANGE_BY_SUFFIX = {
|
||||
'_AL': 'SOR',
|
||||
'_NX': 'NXT',
|
||||
'': 'KRX',
|
||||
}
|
||||
|
||||
|
||||
def _limits() -> dict:
|
||||
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def _exchange_for(suffix: str) -> str:
|
||||
if suffix not in _EXCHANGE_BY_SUFFIX:
|
||||
raise ValueError(f'unknown routing suffix: {suffix!r}')
|
||||
return _EXCHANGE_BY_SUFFIX[suffix]
|
||||
|
||||
|
||||
def submit(account_label: str, side: str, symbol: str, qty: int,
|
||||
price: Optional[int], order_type: str, routing_suffix: str,
|
||||
dry_run: bool = True, card_id: Optional[str] = None) -> dict:
|
||||
sidecar.guard_or_raise()
|
||||
|
||||
if side not in ('BUY', 'SELL'):
|
||||
raise ValueError(f'invalid side: {side}')
|
||||
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
|
||||
raise ValueError(f'invalid order_type: {order_type}')
|
||||
if order_type in ('LIMIT', 'AGGRESSIVE_LIMIT') and not price:
|
||||
raise ValueError(f'{order_type} requires price')
|
||||
|
||||
tr_id = TR_BUY if side == 'BUY' else TR_SELL
|
||||
exchange = _exchange_for(routing_suffix)
|
||||
is_market = order_type == 'MARKET'
|
||||
|
||||
body = {
|
||||
'dmst_stex_tp': exchange,
|
||||
'stk_cd': symbol,
|
||||
'ord_qty': str(qty),
|
||||
'ord_uv': '' if is_market else str(price),
|
||||
'trde_tp': TRDE_TP_MARKET if is_market else TRDE_TP_LIMIT,
|
||||
'cond_uv': '',
|
||||
}
|
||||
|
||||
payload = {
|
||||
'card_id': card_id,
|
||||
'account': account_label,
|
||||
'side': side,
|
||||
'symbol': symbol,
|
||||
'qty': qty,
|
||||
'price': price,
|
||||
'order_type': order_type,
|
||||
'routing_suffix': routing_suffix,
|
||||
'exchange': exchange,
|
||||
'tr_id': tr_id,
|
||||
'dry_run': dry_run,
|
||||
'idem_hash': ledger.idempotency_hash(account_label, symbol, side, qty, price or 0),
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
ledger.append('dryrun', dict(payload, body=body))
|
||||
return {'ok': True, 'dry_run': True, 'payload': payload, 'body': body}
|
||||
|
||||
if ledger.find_recent_idempotency(payload['idem_hash']):
|
||||
ledger.append('rejected', dict(payload, reason='IDEMPOTENCY_DUP'))
|
||||
return {'ok': False, 'reason': 'IDEMPOTENCY_DUP', 'payload': payload}
|
||||
|
||||
try:
|
||||
url = kc.base_url() + '/api/dostk/ordr'
|
||||
resp = kc._http_post_json(url, body, kc.auth_headers(account_label, tr_id=tr_id))
|
||||
if resp.get('return_code', 0) != 0:
|
||||
msg = str(resp.get('return_msg') or '')
|
||||
if '8005' in msg or 'Token이 유효하지 않습니다' in msg:
|
||||
kc.issue_token(account_label, force=True)
|
||||
resp = kc._http_post_json(url, body, kc.auth_headers(account_label, tr_id=tr_id))
|
||||
except Exception as e:
|
||||
ledger.append('failed', dict(payload, error=repr(e)))
|
||||
return {'ok': False, 'reason': 'NETWORK', 'error': repr(e), 'payload': payload}
|
||||
|
||||
# 키움 kt10000/kt10001 명세 응답 필드: ord_no, dmst_stex_tp, return_code, return_msg
|
||||
# (resp가 dict 아닌 경우는 위 try의 .get 호출에서 AttributeError로 NETWORK 분기됨)
|
||||
return_code = resp.get('return_code')
|
||||
if return_code != 0:
|
||||
ledger.append('rejected', dict(payload, response=resp))
|
||||
return {'ok': False, 'reason': 'BROKER_REJECT', 'response': resp, 'payload': payload}
|
||||
|
||||
ord_no = resp.get('ord_no', '')
|
||||
ledger.append('submitted', dict(payload, ord_no=ord_no,
|
||||
response_summary={'return_code': return_code,
|
||||
'return_msg': resp.get('return_msg', '')}))
|
||||
return {'ok': True, 'ord_no': ord_no, 'response': resp, 'payload': payload}
|
||||
|
||||
|
||||
def _post_order_tr(tr_id: str, account_label: str, body: dict) -> dict:
|
||||
"""주문 ordr endpoint POST + 토큰 만료 시 1회 재시도. 응답 raw dict 반환."""
|
||||
url = kc.base_url() + '/api/dostk/ordr'
|
||||
resp = kc._http_post_json(url, body, kc.auth_headers(account_label, tr_id=tr_id))
|
||||
if resp.get('return_code', 0) != 0:
|
||||
msg = str(resp.get('return_msg') or '')
|
||||
if '8005' in msg or 'Token이 유효하지 않습니다' in msg:
|
||||
kc.issue_token(account_label, force=True)
|
||||
resp = kc._http_post_json(url, body, kc.auth_headers(account_label, tr_id=tr_id))
|
||||
return resp
|
||||
|
||||
|
||||
def cancel_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
cancel_qty: int = 0, routing_suffix: str = '',
|
||||
dry_run: bool = True, card_id: Optional[str] = None) -> dict:
|
||||
"""미체결 주문 취소 (kt10003).
|
||||
|
||||
cancel_qty=0 → 잔량 전부 취소 (키움 명세: '0' 입력시 잔량 전부 취소).
|
||||
routing_suffix 는 원주문의 거래소와 동일해야 함 — 호출자가 ka10075 응답의
|
||||
routing_suffix 그대로 전달하는 게 안전.
|
||||
"""
|
||||
sidecar.guard_or_raise()
|
||||
|
||||
if not orig_ord_no:
|
||||
raise ValueError('orig_ord_no required')
|
||||
if not symbol:
|
||||
raise ValueError('symbol required')
|
||||
if cancel_qty < 0:
|
||||
raise ValueError(f'cancel_qty must be >= 0 (got {cancel_qty})')
|
||||
|
||||
exchange = _exchange_for(routing_suffix)
|
||||
body = {
|
||||
'dmst_stex_tp': exchange,
|
||||
'orig_ord_no': str(orig_ord_no),
|
||||
'stk_cd': symbol,
|
||||
'cncl_qty': str(cancel_qty),
|
||||
}
|
||||
|
||||
payload = {
|
||||
'card_id': card_id,
|
||||
'account': account_label,
|
||||
'symbol': symbol,
|
||||
'orig_ord_no': orig_ord_no,
|
||||
'cancel_qty': cancel_qty,
|
||||
'routing_suffix': routing_suffix,
|
||||
'exchange': exchange,
|
||||
'tr_id': TR_CANCEL,
|
||||
'dry_run': dry_run,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
ledger.append('dryrun', dict(payload, body=body, kind='cancel'))
|
||||
return {'ok': True, 'dry_run': True, 'payload': payload, 'body': body}
|
||||
|
||||
try:
|
||||
resp = _post_order_tr(TR_CANCEL, account_label, body)
|
||||
except Exception as e:
|
||||
ledger.append('failed', dict(payload, error=repr(e), kind='cancel'))
|
||||
return {'ok': False, 'reason': 'NETWORK', 'error': repr(e), 'payload': payload}
|
||||
|
||||
return_code = resp.get('return_code')
|
||||
if return_code != 0:
|
||||
ledger.append('cancel_rejected', dict(payload, response=resp))
|
||||
return {'ok': False, 'reason': 'BROKER_REJECT', 'response': resp, 'payload': payload}
|
||||
|
||||
new_ord_no = resp.get('ord_no', '')
|
||||
ledger.append('cancel_submitted', dict(payload, new_ord_no=new_ord_no,
|
||||
response_summary={'return_code': return_code,
|
||||
'return_msg': resp.get('return_msg', '')}))
|
||||
return {'ok': True, 'new_ord_no': new_ord_no, 'response': resp, 'payload': payload}
|
||||
|
||||
|
||||
def modify_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
modify_qty: int, modify_price: int,
|
||||
routing_suffix: str = '',
|
||||
dry_run: bool = True, card_id: Optional[str] = None) -> dict:
|
||||
"""미체결 주문 정정 (kt10002).
|
||||
|
||||
modify_qty / modify_price 둘 다 필수 — 키움 명세상 mdfy_qty/mdfy_uv 모두 Required.
|
||||
수량만 바꿀 땐 기존 가격, 가격만 바꿀 땐 기존 수량을 그대로 전달.
|
||||
시장가 주문은 정정 불가 (mdfy_uv 가 가격이라 0 불가) — 호출 전에 차단.
|
||||
routing_suffix 는 원주문의 거래소와 동일해야 함.
|
||||
"""
|
||||
sidecar.guard_or_raise()
|
||||
|
||||
if not orig_ord_no:
|
||||
raise ValueError('orig_ord_no required')
|
||||
if not symbol:
|
||||
raise ValueError('symbol required')
|
||||
if modify_qty <= 0:
|
||||
raise ValueError(f'modify_qty must be > 0 (got {modify_qty})')
|
||||
if modify_price <= 0:
|
||||
raise ValueError(f'modify_price must be > 0 — 시장가 주문은 정정 불가, 취소 후 신규 발주')
|
||||
|
||||
exchange = _exchange_for(routing_suffix)
|
||||
body = {
|
||||
'dmst_stex_tp': exchange,
|
||||
'orig_ord_no': str(orig_ord_no),
|
||||
'stk_cd': symbol,
|
||||
'mdfy_qty': str(modify_qty),
|
||||
'mdfy_uv': str(modify_price),
|
||||
'mdfy_cond_uv': '',
|
||||
}
|
||||
|
||||
payload = {
|
||||
'card_id': card_id,
|
||||
'account': account_label,
|
||||
'symbol': symbol,
|
||||
'orig_ord_no': orig_ord_no,
|
||||
'modify_qty': modify_qty,
|
||||
'modify_price': modify_price,
|
||||
'routing_suffix': routing_suffix,
|
||||
'exchange': exchange,
|
||||
'tr_id': TR_MODIFY,
|
||||
'dry_run': dry_run,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
ledger.append('dryrun', dict(payload, body=body, kind='modify'))
|
||||
return {'ok': True, 'dry_run': True, 'payload': payload, 'body': body}
|
||||
|
||||
try:
|
||||
resp = _post_order_tr(TR_MODIFY, account_label, body)
|
||||
except Exception as e:
|
||||
ledger.append('failed', dict(payload, error=repr(e), kind='modify'))
|
||||
return {'ok': False, 'reason': 'NETWORK', 'error': repr(e), 'payload': payload}
|
||||
|
||||
return_code = resp.get('return_code')
|
||||
if return_code != 0:
|
||||
ledger.append('modify_rejected', dict(payload, response=resp))
|
||||
return {'ok': False, 'reason': 'BROKER_REJECT', 'response': resp, 'payload': payload}
|
||||
|
||||
new_ord_no = resp.get('ord_no', '')
|
||||
ledger.append('modify_submitted', dict(payload, new_ord_no=new_ord_no,
|
||||
response_summary={'return_code': return_code,
|
||||
'return_msg': resp.get('return_msg', '')}))
|
||||
return {'ok': True, 'new_ord_no': new_ord_no, 'response': resp, 'payload': payload}
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Append-only 주문 ledger.
|
||||
|
||||
state/order_log.jsonl 에 매매 모든 라이프사이클(card_issued, pin_issued, approved,
|
||||
rejected, expired, canceled, submitted, filled, partial, failed, dryrun)을 KST 타임스탬프와
|
||||
함께 한 줄씩 기록한다. 토큰·시크릿 평문 기록을 차단한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def _load_limits() -> dict:
|
||||
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def _ledger_path() -> Path:
|
||||
return WORKSPACE_ROOT / _load_limits()['ledger']['log_file']
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(KST).isoformat()
|
||||
|
||||
|
||||
def _mask(value: str, keep: int = 4) -> str:
|
||||
if not value:
|
||||
return '***'
|
||||
s = str(value)
|
||||
if len(s) <= keep:
|
||||
return '***'
|
||||
return s[:keep] + '*' * (len(s) - keep)
|
||||
|
||||
|
||||
def _scrub_secrets(payload: dict) -> dict:
|
||||
out = {}
|
||||
for k, v in payload.items():
|
||||
kl = k.lower()
|
||||
if any(s in kl for s in ('token', 'secret', 'pin', 'password', 'appkey', 'appsecret')):
|
||||
out[k] = _mask(v, 2) if v is not None else None
|
||||
elif isinstance(v, dict):
|
||||
out[k] = _scrub_secrets(v)
|
||||
elif isinstance(v, list):
|
||||
out[k] = [_scrub_secrets(x) if isinstance(x, dict) else x for x in v]
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def append(event: str, payload: dict) -> None:
|
||||
limits = _load_limits()
|
||||
allowed = set(limits['ledger']['events'])
|
||||
if event not in allowed:
|
||||
raise ValueError(f'unknown ledger event: {event}')
|
||||
safe_payload = _scrub_secrets(payload) if limits['ledger']['mask_token_in_logs'] else payload
|
||||
record = {'ts': _now_iso(), 'event': event, 'payload': safe_payload}
|
||||
path = _ledger_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open('a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + '\n')
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def idempotency_hash(account: str, symbol: str, side: str, qty: int, price: int | str) -> str:
|
||||
"""동일 (계좌·종목·방향·수량·가격) 60초 윈도우 중복 주문 차단용 해시."""
|
||||
minute = int(datetime.now(KST).timestamp() // 60)
|
||||
key = f'{account}|{symbol}|{side}|{qty}|{price}|{minute}'
|
||||
return hashlib.sha256(key.encode('utf-8')).hexdigest()[:16]
|
||||
|
||||
|
||||
def find_recent_idempotency(h: str, within_seconds: int = 90) -> Optional[dict]:
|
||||
path = _ledger_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
cutoff = datetime.now(KST) - timedelta(seconds=within_seconds)
|
||||
with path.open('r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
ts = datetime.fromisoformat(rec['ts'])
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
if ts < cutoff:
|
||||
return None
|
||||
if rec.get('payload', {}).get('idem_hash') == h:
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
def last_event_for_symbol(account: str, symbol: str, events: tuple[str, ...] = ('submitted', 'filled', 'partial')) -> Optional[dict]:
|
||||
"""동일 종목 딜레이 계산을 위해 가장 최근 '실주문 접수 이후' 매칭 이벤트를 반환.
|
||||
|
||||
rejected/canceled/expired/failed 는 키움 접수 전 실패이므로 쿨다운 대상 아님.
|
||||
"""
|
||||
path = _ledger_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
with path.open('r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if rec.get('event') not in events:
|
||||
continue
|
||||
p = rec.get('payload', {})
|
||||
if p.get('account') == account and p.get('symbol') == symbol:
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
def last_terminal_event(events: tuple[str, ...] = ('submitted', 'filled', 'partial')) -> Optional[dict]:
|
||||
"""전체 거래 간 60초 딜레이 계산을 위해 가장 최근 '실주문 접수 이후' 이벤트를 반환.
|
||||
|
||||
rejected/canceled/expired/failed 는 키움 접수 전 실패이므로 쿨다운 대상 아님.
|
||||
"""
|
||||
path = _ledger_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
with path.open('r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if rec.get('event') in events:
|
||||
return rec
|
||||
return None
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"_meta": {
|
||||
"schema_version": 1,
|
||||
"last_updated": "2026-05-06",
|
||||
"note": "변경 시 별도 커밋. guards 단위테스트가 이 파일을 검증. 손으로 풀지 말고 항상 이 파일을 통해 조정."
|
||||
},
|
||||
|
||||
"enabled": false,
|
||||
"kill_switch_file": "state/orders_disabled",
|
||||
|
||||
"accounts_whitelist": ["일반", "ISA", "가희_일반", "가희_ISA"],
|
||||
"owner_accounts": ["일반", "ISA"],
|
||||
"spouse_accounts": ["가희_일반", "가희_ISA"],
|
||||
|
||||
"limits": {
|
||||
"single_order_max_won": null,
|
||||
"daily_total_max_won": null,
|
||||
"balance_ratio_max": null,
|
||||
"price_band_pct_soft": null,
|
||||
"price_band_pct_hard": 30
|
||||
},
|
||||
|
||||
"delays": {
|
||||
"between_orders_seconds": 5
|
||||
},
|
||||
|
||||
"pin": {
|
||||
"owner_length": 4,
|
||||
"owner_charset": "digits",
|
||||
"spouse_length": 8,
|
||||
"spouse_charset": "alnum_no_confusing",
|
||||
"alnum_no_confusing_chars": "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789",
|
||||
"expiry_seconds": 120,
|
||||
"max_attempts": 1
|
||||
},
|
||||
|
||||
"trading_hours": {
|
||||
"tz": "Asia/Seoul",
|
||||
"krx_regular_start": "09:00:30",
|
||||
"krx_regular_end": "15:20:00",
|
||||
"krx_closing_auction_start": "15:20:00",
|
||||
"krx_closing_auction_end": "15:30:00",
|
||||
"nxt_pre_start": "08:00:00",
|
||||
"nxt_pre_end": "09:00:00",
|
||||
"nxt_after_start": "15:30:00",
|
||||
"nxt_after_end": "20:00:00",
|
||||
"block_outside": true,
|
||||
"holiday_state_file": "state/market_holidays.json"
|
||||
},
|
||||
|
||||
"routing": {
|
||||
"default": "AL",
|
||||
"force_options": ["AL", "NX", "KRX"],
|
||||
"suffix_AL": "_AL",
|
||||
"suffix_NX": "_NX",
|
||||
"suffix_KRX": ""
|
||||
},
|
||||
|
||||
"market_order": {
|
||||
"allowed": true,
|
||||
"natural_language_market": ["시장가"],
|
||||
"natural_language_aggressive_limit": ["지금 바로", "즉시", "빨리", "당장"],
|
||||
"aggressive_limit_ticks": 1,
|
||||
"show_orderbook_depth": 3,
|
||||
"estimate_slippage": true
|
||||
},
|
||||
|
||||
"guards": {
|
||||
"block_market_holidays": true,
|
||||
"block_trading_halt": true,
|
||||
"block_vi": true,
|
||||
"block_outside_trading_hours": true,
|
||||
"require_balance_check_for_buy": true,
|
||||
"require_position_check_for_sell": true,
|
||||
"block_market_order_when_orderbook_thin": false
|
||||
},
|
||||
|
||||
"card": {
|
||||
"highlight_fields": ["side", "account", "symbol_name", "price"],
|
||||
"highlight_marker": "▶",
|
||||
"highlight_bold_markdown": true,
|
||||
"buy_emoji": "🛒",
|
||||
"sell_emoji": "💰",
|
||||
"warning_emoji": "⚠️",
|
||||
"show_orderbook_for_market": true,
|
||||
"orderbook_depth": 3,
|
||||
"show_balance_ratio": true
|
||||
},
|
||||
|
||||
"ledger": {
|
||||
"log_file": "state/order_log.jsonl",
|
||||
"events": ["card_issued", "pin_issued", "amended", "approved", "rejected", "expired", "canceled", "submitted", "filled", "partial", "failed", "dryrun", "cancel_submitted", "cancel_rejected", "cancel_confirmed", "cancel_unconfirmed_timeout", "modify_submitted", "modify_rejected"],
|
||||
"mask_token_in_logs": true
|
||||
},
|
||||
|
||||
"telegram": {
|
||||
"dm_only": true,
|
||||
"chat_id_whitelist_env": "OPENCLAW_OWNER_CHAT_ID",
|
||||
"block_groups": true,
|
||||
"block_when_agent_env_set": "OPENCLAW_AGENT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
PIN 발급·검증·만료.
|
||||
|
||||
본인 계좌(일반·ISA): 숫자 4자리.
|
||||
가희 계좌(가희_일반·가희_ISA): 영숫자 8자리, 혼동 글자(0/O/o/1/l/I) 제외 55자.
|
||||
120초 만료, 1회용, 1회 시도.
|
||||
|
||||
동시 활성 카드는 1개로 제한.
|
||||
|
||||
상태는 파일(state/active_card.json)에 저장 — CLI 가 매번 새 프로세스로 호출돼도
|
||||
같은 활성 카드를 본다. 동시성은 fcntl flock 으로 직렬화.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
|
||||
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DEFAULT_STATE_FILE = WORKSPACE_ROOT / 'state' / 'active_card.json'
|
||||
|
||||
|
||||
def _limits() -> dict:
|
||||
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def _account_groups() -> tuple[set[str], set[str]]:
|
||||
d = _limits()
|
||||
return set(d['owner_accounts']), set(d['spouse_accounts'])
|
||||
|
||||
|
||||
def issue_pin(account_label: str) -> str:
|
||||
cfg = _limits()['pin']
|
||||
owners, spouses = _account_groups()
|
||||
if account_label in spouses:
|
||||
chars = cfg['alnum_no_confusing_chars']
|
||||
length = cfg['spouse_length']
|
||||
elif account_label in owners:
|
||||
chars = '0123456789'
|
||||
length = cfg['owner_length']
|
||||
else:
|
||||
raise ValueError(f'unknown account label: {account_label}')
|
||||
return ''.join(secrets.choice(chars) for _ in range(length))
|
||||
|
||||
|
||||
def issue_card_id() -> str:
|
||||
return ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(4))
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingCard:
|
||||
card_id: str
|
||||
pin: str
|
||||
account_label: str
|
||||
issued_at: float
|
||||
expiry_seconds: int
|
||||
payload: dict = field(default_factory=dict)
|
||||
attempts: int = 0
|
||||
consumed: bool = False
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
return (time.time() - self.issued_at) >= self.expiry_seconds
|
||||
|
||||
|
||||
class _FileLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self._fp = None
|
||||
|
||||
def __enter__(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._fp = open(self.path, 'w')
|
||||
fcntl.flock(self._fp, fcntl.LOCK_EX)
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
try:
|
||||
fcntl.flock(self._fp, fcntl.LOCK_UN)
|
||||
finally:
|
||||
self._fp.close()
|
||||
|
||||
|
||||
class PinStore:
|
||||
"""파일 기반 PinStore. 모든 프로세스가 같은 활성 카드를 본다."""
|
||||
|
||||
def __init__(self, state_file: Optional[Path] = None):
|
||||
self._state_file = Path(state_file) if state_file else DEFAULT_STATE_FILE
|
||||
self._lock_file = self._state_file.with_suffix(self._state_file.suffix + '.lock')
|
||||
|
||||
def _read_locked(self) -> Optional[PendingCard]:
|
||||
if not self._state_file.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(self._state_file.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
try:
|
||||
return PendingCard(**data)
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
def _write_locked(self, card: PendingCard) -> None:
|
||||
self._state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._state_file.with_suffix(self._state_file.suffix + '.tmp')
|
||||
tmp.write_text(json.dumps(asdict(card), ensure_ascii=False), encoding='utf-8')
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, self._state_file)
|
||||
|
||||
def _clear_locked(self) -> None:
|
||||
if self._state_file.exists():
|
||||
try:
|
||||
self._state_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def issue(self, account_label: str, payload: Optional[dict] = None) -> PendingCard:
|
||||
cfg = _limits()['pin']
|
||||
with _FileLock(self._lock_file):
|
||||
existing = self._read_locked()
|
||||
if existing is not None and not existing.is_expired() and not existing.consumed:
|
||||
raise RuntimeError('이전 카드가 아직 활성. 처리 후 다시 시도.')
|
||||
card = PendingCard(
|
||||
card_id=issue_card_id(),
|
||||
pin=issue_pin(account_label),
|
||||
account_label=account_label,
|
||||
issued_at=time.time(),
|
||||
expiry_seconds=cfg['expiry_seconds'],
|
||||
payload=payload or {},
|
||||
)
|
||||
self._write_locked(card)
|
||||
return card
|
||||
|
||||
def verify(self, pin_input: str) -> tuple[bool, str, Optional[PendingCard]]:
|
||||
cfg = _limits()['pin']
|
||||
with _FileLock(self._lock_file):
|
||||
card = self._read_locked()
|
||||
if card is None:
|
||||
return False, '활성 카드 없음', None
|
||||
if card.is_expired():
|
||||
self._clear_locked()
|
||||
return False, '만료', card
|
||||
if card.consumed:
|
||||
self._clear_locked()
|
||||
return False, '이미 사용됨', card
|
||||
card.attempts += 1
|
||||
if card.pin != (pin_input or '').strip():
|
||||
if card.attempts >= cfg['max_attempts']:
|
||||
self._clear_locked()
|
||||
return False, 'PIN 불일치, 카드 무효', card
|
||||
self._write_locked(card)
|
||||
return False, 'PIN 불일치', card
|
||||
card.consumed = True
|
||||
self._clear_locked()
|
||||
return True, 'OK', card
|
||||
|
||||
def cancel(self) -> Optional[PendingCard]:
|
||||
with _FileLock(self._lock_file):
|
||||
card = self._read_locked()
|
||||
if card is None:
|
||||
return None
|
||||
self._clear_locked()
|
||||
return card
|
||||
|
||||
def amend(self, payload: dict, account_label: Optional[str] = None) -> Optional[PendingCard]:
|
||||
"""활성 카드의 페이로드 갱신 + PIN 재발급 + 만료 리셋. card_id 는 유지.
|
||||
|
||||
account_label 명시 시 새 계좌 기준으로 PIN 재발급(본인 4자리 / 가희 8자리).
|
||||
활성 카드가 없거나 만료/소비 상태면 None 반환 (호출자가 NO_ACTIVE_CARD 거부).
|
||||
"""
|
||||
cfg = _limits()['pin']
|
||||
with _FileLock(self._lock_file):
|
||||
existing = self._read_locked()
|
||||
if existing is None or existing.is_expired() or existing.consumed:
|
||||
if existing is not None and (existing.is_expired() or existing.consumed):
|
||||
self._clear_locked()
|
||||
return None
|
||||
new_account = account_label or existing.account_label
|
||||
new_card = PendingCard(
|
||||
card_id=existing.card_id,
|
||||
pin=issue_pin(new_account),
|
||||
account_label=new_account,
|
||||
issued_at=time.time(),
|
||||
expiry_seconds=cfg['expiry_seconds'],
|
||||
payload=payload,
|
||||
attempts=0,
|
||||
)
|
||||
self._write_locked(new_card)
|
||||
return new_card
|
||||
|
||||
def peek(self) -> Optional[PendingCard]:
|
||||
with _FileLock(self._lock_file):
|
||||
return self._read_locked()
|
||||
|
||||
def sweep_expired(self) -> Optional[PendingCard]:
|
||||
"""만료된 카드를 정리하고 정리된 카드를 반환 (만료 알림 송신용)."""
|
||||
with _FileLock(self._lock_file):
|
||||
card = self._read_locked()
|
||||
if card is None:
|
||||
return None
|
||||
if card.is_expired() and not card.consumed:
|
||||
self._clear_locked()
|
||||
return card
|
||||
return None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Kill switch.
|
||||
|
||||
state/orders_disabled 파일 존재 시 모든 진입점에서 거부.
|
||||
LLM 에이전트(클로·레이) 세션에서 호출 시 즉시 차단 (OPENCLAW_AGENT 환경변수).
|
||||
|
||||
모든 매매 진입점 함수 첫 줄에서 guard_or_raise() 호출이 강제이다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
class SidecarBlocked(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _limits() -> dict:
|
||||
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def _kill_switch_path() -> Path:
|
||||
return WORKSPACE_ROOT / _limits()['kill_switch_file']
|
||||
|
||||
|
||||
def _agent_env_blocked() -> bool:
|
||||
var = _limits().get('telegram', {}).get('block_when_agent_env_set')
|
||||
return bool(var and os.getenv(var))
|
||||
|
||||
|
||||
def is_disabled() -> bool:
|
||||
return _kill_switch_path().exists()
|
||||
|
||||
|
||||
def guard_or_raise() -> None:
|
||||
if _agent_env_blocked():
|
||||
raise SidecarBlocked('LLM agent session cannot invoke order module (OPENCLAW_AGENT env set)')
|
||||
if is_disabled():
|
||||
raise SidecarBlocked('Order module is disabled (sidecar ON)')
|
||||
|
||||
|
||||
def disable(reason: str = 'manual') -> Path:
|
||||
p = _kill_switch_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(
|
||||
json.dumps({'disabled_at': datetime.now(KST).isoformat(), 'reason': reason}, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
try:
|
||||
os.chmod(p, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return p
|
||||
|
||||
|
||||
def enable() -> bool:
|
||||
p = _kill_switch_path()
|
||||
if not p.exists():
|
||||
return False
|
||||
p.unlink()
|
||||
return True
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
p = _kill_switch_path()
|
||||
if not p.exists():
|
||||
return {'disabled': False, 'path': str(p), 'agent_env_blocked': _agent_env_blocked()}
|
||||
try:
|
||||
meta = json.loads(p.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
meta = {}
|
||||
meta.update({'disabled': True, 'path': str(p), 'agent_env_blocked': _agent_env_blocked()})
|
||||
return meta
|
||||
|
||||
|
||||
def _cli(argv: list[str]) -> int:
|
||||
if not argv or argv[0] == 'status':
|
||||
print(json.dumps(status(), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
cmd = argv[0]
|
||||
if cmd == 'disable':
|
||||
reason = ' '.join(argv[1:]) or 'manual'
|
||||
p = disable(reason)
|
||||
print(f'sidecar disabled: {p}')
|
||||
return 0
|
||||
if cmd == 'enable':
|
||||
ok = enable()
|
||||
print('sidecar enabled' if ok else 'sidecar already enabled (no file)')
|
||||
return 0
|
||||
print(f'unknown command: {cmd}', file=sys.stderr)
|
||||
print('usage: sidecar.py {status | disable [reason] | enable}', file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(_cli(sys.argv[1:]))
|
||||
@@ -0,0 +1,446 @@
|
||||
"""fill_watcher 회귀 테스트.
|
||||
|
||||
- _FillWatcher._poll_once: 부분/완전체결, 사후거절, 타임아웃, 중복 방지, 계좌 묶음 fetch.
|
||||
- 큐 파일 IO: append, read, persist, 빈 큐 처리.
|
||||
- watch(): 큐 append + 데몬 ensure (subprocess.Popen mock).
|
||||
- is_daemon_alive: PID 파일 stale 검출.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from orders import fill_watcher
|
||||
from orders.fill_watcher import Tracked
|
||||
|
||||
|
||||
def _row(ord_no='100001', cntr_qty=0, cntr_uv=0, mdfy_cncl=''):
|
||||
return {
|
||||
'ord_no': ord_no,
|
||||
'ord_tm': '10:00:00',
|
||||
'code': '005930',
|
||||
'name': '삼성전자',
|
||||
'side': 'BUY',
|
||||
'order_qty': 10,
|
||||
'cntr_qty': cntr_qty,
|
||||
'cntr_uv': cntr_uv,
|
||||
'ord_uv': 75000,
|
||||
'order_type': '지정가',
|
||||
'exchange': 'KRX',
|
||||
'comm_src': 'REST API',
|
||||
'mdfy_cncl': mdfy_cncl,
|
||||
}
|
||||
|
||||
|
||||
class FillWatcherPollTests(unittest.TestCase):
|
||||
"""_FillWatcher._poll_once 직접 호출로 격리 (시간 의존 X)."""
|
||||
|
||||
def setUp(self):
|
||||
fill_watcher._reset_for_test()
|
||||
self.sent = []
|
||||
self.fetch = mock.MagicMock(return_value=[])
|
||||
self.fetch_open = mock.MagicMock(return_value=[])
|
||||
fill_watcher.configure(send_func=lambda msg: self.sent.append(msg) or True,
|
||||
fetch_executions=self.fetch,
|
||||
fetch_open_orders=self.fetch_open)
|
||||
|
||||
def _track(self, ord_no='100001', order_qty=10):
|
||||
w = fill_watcher._watcher
|
||||
with w._lock:
|
||||
w._tracked[ord_no] = Tracked(
|
||||
ord_no=ord_no, account='일반', side='BUY', symbol='005930',
|
||||
symbol_name='삼성전자', order_qty=order_qty, price=75000,
|
||||
order_type='LIMIT', card_id='A7K3', started_at=time.time(),
|
||||
)
|
||||
|
||||
def test_no_row_no_alert(self):
|
||||
self._track()
|
||||
self.fetch.return_value = []
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertEqual(self.sent, [])
|
||||
self.assertIn('100001', fill_watcher._peek_for_test())
|
||||
|
||||
def test_full_fill_alerts_and_removes(self):
|
||||
self._track()
|
||||
self.fetch.return_value = [_row(cntr_qty=10, cntr_uv=75050)]
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertEqual(len(self.sent), 1)
|
||||
self.assertIn('체결', self.sent[0])
|
||||
self.assertNotIn('100001', fill_watcher._peek_for_test())
|
||||
|
||||
def test_partial_fill_alerts_and_keeps(self):
|
||||
self._track()
|
||||
self.fetch.return_value = [_row(cntr_qty=3, cntr_uv=75050)]
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertIn('부분체결', self.sent[0])
|
||||
self.assertIn('100001', fill_watcher._peek_for_test())
|
||||
self.assertEqual(fill_watcher._peek_for_test()['100001'].last_cntr_qty, 3)
|
||||
|
||||
def test_partial_then_full(self):
|
||||
self._track()
|
||||
self.fetch.return_value = [_row(cntr_qty=3, cntr_uv=75050)]
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.fetch.return_value = [_row(cntr_qty=10, cntr_uv=75080)]
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertEqual(len(self.sent), 2)
|
||||
self.assertIn('부분체결', self.sent[0])
|
||||
self.assertIn('체결', self.sent[1])
|
||||
self.assertNotIn('100001', fill_watcher._peek_for_test())
|
||||
|
||||
def test_post_reject(self):
|
||||
self._track()
|
||||
self.fetch.return_value = [_row(cntr_qty=0, mdfy_cncl='취소')]
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertIn('사후거절', self.sent[0])
|
||||
self.assertNotIn('100001', fill_watcher._peek_for_test())
|
||||
|
||||
def test_timeout(self):
|
||||
self._track()
|
||||
with fill_watcher._watcher._lock:
|
||||
fill_watcher._watcher._tracked['100001'].started_at = time.time() - 1801
|
||||
self.fetch.return_value = []
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertIn('미체결', self.sent[0])
|
||||
self.assertNotIn('100001', fill_watcher._peek_for_test())
|
||||
|
||||
def test_no_duplicate_alert_on_same_state(self):
|
||||
self._track()
|
||||
self.fetch.return_value = [_row(cntr_qty=3, cntr_uv=75050)]
|
||||
fill_watcher._watcher._poll_once()
|
||||
fill_watcher._watcher._poll_once()
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertEqual(len(self.sent), 1)
|
||||
|
||||
def test_one_fetch_per_account(self):
|
||||
self._track(ord_no='100001')
|
||||
self._track(ord_no='100002')
|
||||
self.fetch.return_value = [
|
||||
_row(ord_no='100001', cntr_qty=10, cntr_uv=75100),
|
||||
_row(ord_no='100002', cntr_qty=5, cntr_uv=75100),
|
||||
]
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertEqual(self.fetch.call_count, 1)
|
||||
self.assertEqual(len(self.sent), 2)
|
||||
|
||||
|
||||
class CancelWatcherTests(unittest.TestCase):
|
||||
"""cancel kind 회귀 — ka10075 폴링으로 원주문이 사라지면 확정."""
|
||||
|
||||
def setUp(self):
|
||||
fill_watcher._reset_for_test()
|
||||
self.sent = []
|
||||
self.fetch_exec = mock.MagicMock(return_value=[])
|
||||
self.fetch_open = mock.MagicMock(return_value=[])
|
||||
fill_watcher.configure(send_func=lambda msg: self.sent.append(msg) or True,
|
||||
fetch_executions=self.fetch_exec,
|
||||
fetch_open_orders=self.fetch_open)
|
||||
|
||||
def _track_cancel(self, new_ord_no='200001', orig_ord_no='100001',
|
||||
cancel_qty=10, started_at=None):
|
||||
w = fill_watcher._watcher
|
||||
with w._lock:
|
||||
w._tracked[new_ord_no] = Tracked(
|
||||
ord_no=new_ord_no, account='일반', side='BUY', symbol='005930',
|
||||
symbol_name='삼성전자', order_qty=cancel_qty, price=None,
|
||||
order_type='CANCEL', card_id='', started_at=started_at or time.time(),
|
||||
kind='cancel', orig_ord_no=orig_ord_no,
|
||||
)
|
||||
|
||||
def _track_fill(self, ord_no='100001', order_qty=10):
|
||||
w = fill_watcher._watcher
|
||||
with w._lock:
|
||||
w._tracked[ord_no] = Tracked(
|
||||
ord_no=ord_no, account='일반', side='BUY', symbol='005930',
|
||||
symbol_name='삼성전자', order_qty=order_qty, price=75000,
|
||||
order_type='LIMIT', card_id='A7K3', started_at=time.time(),
|
||||
)
|
||||
|
||||
def test_cancel_confirmed_when_orig_disappears(self):
|
||||
self._track_cancel()
|
||||
self.fetch_open.return_value = [] # 원주문 사라짐
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertEqual(len(self.sent), 1)
|
||||
self.assertIn('취소 확인', self.sent[0])
|
||||
self.assertNotIn('200001', fill_watcher._peek_for_test())
|
||||
|
||||
def test_cancel_confirmed_also_stops_original_fill_watch(self):
|
||||
"""취소 확정 시 원주문 체결 감시도 같이 끝낸다."""
|
||||
self._track_fill(ord_no='100001')
|
||||
self._track_cancel(new_ord_no='200001', orig_ord_no='100001')
|
||||
self.fetch_open.return_value = [] # 원주문 사라짐 → 취소 확정
|
||||
self.fetch_exec.return_value = [] # kt00007 에 취소 상태가 안 잡혀도
|
||||
fill_watcher._watcher._poll_once()
|
||||
tracked = fill_watcher._peek_for_test()
|
||||
self.assertNotIn('200001', tracked)
|
||||
self.assertNotIn('100001', tracked)
|
||||
|
||||
def test_cancel_pending_when_orig_still_open(self):
|
||||
self._track_cancel()
|
||||
self.fetch_open.return_value = [{'ord_no': '100001'}] # 원주문 아직 미체결
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertEqual(self.sent, [])
|
||||
self.assertIn('200001', fill_watcher._peek_for_test())
|
||||
|
||||
def test_cancel_timeout_when_orig_still_open_after_30min(self):
|
||||
self._track_cancel(started_at=time.time() - 1801)
|
||||
self.fetch_open.return_value = [{'ord_no': '100001'}]
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertEqual(len(self.sent), 1)
|
||||
self.assertIn('미확인', self.sent[0])
|
||||
self.assertNotIn('200001', fill_watcher._peek_for_test())
|
||||
|
||||
def test_cancel_fetch_error_skips_quietly(self):
|
||||
self._track_cancel()
|
||||
self.fetch_open.side_effect = RuntimeError('network')
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertEqual(self.sent, [])
|
||||
# 추적 유지 (다음 폴링으로 미룸)
|
||||
self.assertIn('200001', fill_watcher._peek_for_test())
|
||||
|
||||
def test_executions_not_fetched_when_only_cancel_watches(self):
|
||||
self._track_cancel()
|
||||
self.fetch_open.return_value = [{'ord_no': '100001'}]
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.fetch_exec.assert_not_called()
|
||||
self.fetch_open.assert_called_once_with('일반')
|
||||
|
||||
def test_open_orders_not_fetched_when_only_fill_watches(self):
|
||||
self._track_fill()
|
||||
self.fetch_exec.return_value = []
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.fetch_exec.assert_called_once_with('일반')
|
||||
self.fetch_open.assert_not_called()
|
||||
|
||||
def test_user_cancel_suppresses_post_reject_message(self):
|
||||
"""fill watch 중인 주문에 cancel watch 가 같이 걸려있으면 mdfy_cncl 떠도 사후거절 메시지 X."""
|
||||
self._track_fill(ord_no='100001')
|
||||
self._track_cancel(new_ord_no='200001', orig_ord_no='100001')
|
||||
self.fetch_exec.return_value = [{
|
||||
'ord_no': '100001', 'cntr_qty': 0, 'cntr_uv': 0, 'mdfy_cncl': '취소',
|
||||
}]
|
||||
self.fetch_open.return_value = [{'ord_no': '100001'}] # 아직 미체결 목록에 있음
|
||||
fill_watcher._watcher._poll_once()
|
||||
# 사후거절 메시지 억제 — fill watch 만 해제, 취소 확정은 cancel watch 가 별도로 보냄
|
||||
self.assertEqual(self.sent, [])
|
||||
self.assertNotIn('100001', fill_watcher._peek_for_test())
|
||||
self.assertIn('200001', fill_watcher._peek_for_test())
|
||||
|
||||
def test_broker_post_reject_still_alerts_when_no_cancel_watch(self):
|
||||
"""사용자 cancel 아닌 broker 사후거절은 그대로 알림."""
|
||||
self._track_fill(ord_no='100001')
|
||||
self.fetch_exec.return_value = [{
|
||||
'ord_no': '100001', 'cntr_qty': 0, 'cntr_uv': 0, 'mdfy_cncl': '취소',
|
||||
}]
|
||||
fill_watcher._watcher._poll_once()
|
||||
self.assertEqual(len(self.sent), 1)
|
||||
self.assertIn('사후거절', self.sent[0])
|
||||
|
||||
|
||||
class FillWatcherQueueIOTests(unittest.TestCase):
|
||||
"""큐 파일 read/append/persist."""
|
||||
|
||||
def setUp(self):
|
||||
fill_watcher._reset_for_test()
|
||||
self.addCleanup(fill_watcher._reset_for_test)
|
||||
|
||||
def test_append_and_read(self):
|
||||
e1 = {'ord_no': '100001', 'account': '일반', 'side': 'BUY',
|
||||
'symbol': '005930', 'symbol_name': '삼성전자', 'order_qty': 10,
|
||||
'price': 75000, 'order_type': 'LIMIT', 'card_id': 'A7K3',
|
||||
'started_at': 1.0, 'last_cntr_qty': 0}
|
||||
e2 = dict(e1, ord_no='100002', card_id='B2K9')
|
||||
fill_watcher.append_queue_entry(e1)
|
||||
fill_watcher.append_queue_entry(e2)
|
||||
out = fill_watcher.read_queue()
|
||||
self.assertEqual(len(out), 2)
|
||||
self.assertEqual(out[0]['ord_no'], '100001')
|
||||
self.assertEqual(out[1]['ord_no'], '100002')
|
||||
|
||||
def test_read_empty_queue(self):
|
||||
self.assertEqual(fill_watcher.read_queue(), [])
|
||||
|
||||
def test_persist_overwrites(self):
|
||||
e = {'ord_no': '100001', 'account': '일반', 'side': 'BUY',
|
||||
'symbol': '005930', 'symbol_name': '삼성전자', 'order_qty': 10,
|
||||
'price': 75000, 'order_type': 'LIMIT', 'card_id': 'A7K3',
|
||||
'started_at': 1.0, 'last_cntr_qty': 0}
|
||||
fill_watcher.append_queue_entry(e)
|
||||
fill_watcher.append_queue_entry(dict(e, ord_no='100002'))
|
||||
fill_watcher.persist_queue([dict(e, ord_no='100003')])
|
||||
out = fill_watcher.read_queue()
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0]['ord_no'], '100003')
|
||||
|
||||
def test_persist_empty_removes_file(self):
|
||||
e = {'ord_no': '100001', 'account': '일반', 'side': 'BUY',
|
||||
'symbol': '005930', 'symbol_name': '삼성전자', 'order_qty': 10,
|
||||
'price': 75000, 'order_type': 'LIMIT', 'card_id': 'A7K3',
|
||||
'started_at': 1.0, 'last_cntr_qty': 0}
|
||||
fill_watcher.append_queue_entry(e)
|
||||
self.assertTrue(fill_watcher.QUEUE_FILE.exists())
|
||||
fill_watcher.persist_queue([])
|
||||
self.assertFalse(fill_watcher.QUEUE_FILE.exists())
|
||||
|
||||
def test_read_skips_malformed_lines(self):
|
||||
fill_watcher.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
fill_watcher.QUEUE_FILE.write_text(
|
||||
'{"ord_no": "100001", "account": "일반", "side": "BUY", '
|
||||
'"symbol": "005930", "symbol_name": "삼성전자", "order_qty": 10, '
|
||||
'"price": 75000, "order_type": "LIMIT", "card_id": "A7K3", '
|
||||
'"started_at": 1.0, "last_cntr_qty": 0}\n'
|
||||
'\n'
|
||||
'NOT_JSON_GARBAGE\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
out = fill_watcher.read_queue()
|
||||
self.assertEqual(len(out), 1)
|
||||
|
||||
|
||||
class FillWatcherWatchEntryTests(unittest.TestCase):
|
||||
"""watch() = 큐 append + 데몬 ensure_running. subprocess.Popen mock."""
|
||||
|
||||
def setUp(self):
|
||||
fill_watcher._reset_for_test()
|
||||
self.addCleanup(fill_watcher._reset_for_test)
|
||||
# subprocess.Popen mock — 실제 데몬 fork 막음
|
||||
self.popen = mock.patch.object(fill_watcher.subprocess, 'Popen').start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
|
||||
def test_watch_appends_to_queue(self):
|
||||
fill_watcher.watch(ord_no='100001', account='일반', side='BUY',
|
||||
symbol='005930', symbol_name='삼성전자', order_qty=10,
|
||||
price=75000, order_type='LIMIT', card_id='A7K3')
|
||||
out = fill_watcher.read_queue()
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0]['ord_no'], '100001')
|
||||
|
||||
def test_watch_starts_daemon(self):
|
||||
fill_watcher.watch(ord_no='100001', account='일반', side='BUY',
|
||||
symbol='005930', symbol_name='삼성전자', order_qty=10,
|
||||
price=75000, order_type='LIMIT', card_id='A7K3')
|
||||
self.popen.assert_called_once()
|
||||
# 인자 검증 — orders.fill_watcher_daemon 모듈 호출
|
||||
args = self.popen.call_args[0][0]
|
||||
self.assertEqual(args[1], '-m')
|
||||
self.assertEqual(args[2], 'orders.fill_watcher_daemon')
|
||||
|
||||
def test_watch_dedupe_same_ord_no(self):
|
||||
fill_watcher.watch(ord_no='100001', account='일반', side='BUY',
|
||||
symbol='005930', symbol_name='삼성전자', order_qty=10,
|
||||
price=75000, order_type='LIMIT', card_id='A7K3')
|
||||
fill_watcher.watch(ord_no='100001', account='일반', side='BUY',
|
||||
symbol='005930', symbol_name='삼성전자', order_qty=99,
|
||||
price=99999, order_type='LIMIT', card_id='B2K9')
|
||||
out = fill_watcher.read_queue()
|
||||
self.assertEqual(len(out), 1)
|
||||
# 첫 등록값 유지
|
||||
self.assertEqual(out[0]['order_qty'], 10)
|
||||
self.assertEqual(out[0]['card_id'], 'A7K3')
|
||||
|
||||
def test_watch_empty_ord_no_ignored(self):
|
||||
fill_watcher.watch(ord_no='', account='일반', side='BUY',
|
||||
symbol='005930', symbol_name='삼성전자', order_qty=10,
|
||||
price=75000, order_type='LIMIT', card_id='A7K3')
|
||||
self.assertEqual(fill_watcher.read_queue(), [])
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_watch_cancel_appends_with_kind(self):
|
||||
fill_watcher.watch_cancel(new_ord_no='200001', orig_ord_no='100001',
|
||||
account='일반', side='BUY', symbol='005930',
|
||||
symbol_name='삼성전자', cancel_qty=7)
|
||||
out = fill_watcher.read_queue()
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0]['ord_no'], '200001')
|
||||
self.assertEqual(out[0]['orig_ord_no'], '100001')
|
||||
self.assertEqual(out[0]['kind'], 'cancel')
|
||||
self.assertEqual(out[0]['order_qty'], 7)
|
||||
self.popen.assert_called_once()
|
||||
|
||||
def test_watch_cancel_empty_args_ignored(self):
|
||||
fill_watcher.watch_cancel(new_ord_no='', orig_ord_no='100001',
|
||||
account='일반', side='BUY', symbol='005930',
|
||||
symbol_name='삼성전자', cancel_qty=10)
|
||||
fill_watcher.watch_cancel(new_ord_no='200001', orig_ord_no='',
|
||||
account='일반', side='BUY', symbol='005930',
|
||||
symbol_name='삼성전자', cancel_qty=10)
|
||||
self.assertEqual(fill_watcher.read_queue(), [])
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_watch_skips_popen_when_daemon_alive(self):
|
||||
# 살아있는 데몬 시뮬레이션 — 현재 프로세스 PID 사용
|
||||
fill_watcher.PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
fill_watcher.PID_FILE.write_text(str(os.getpid()), encoding='utf-8')
|
||||
fill_watcher.watch(ord_no='100001', account='일반', side='BUY',
|
||||
symbol='005930', symbol_name='삼성전자', order_qty=10,
|
||||
price=75000, order_type='LIMIT', card_id='A7K3')
|
||||
self.popen.assert_not_called()
|
||||
self.assertEqual(len(fill_watcher.read_queue()), 1)
|
||||
|
||||
|
||||
class FillWatcherDaemonAliveTests(unittest.TestCase):
|
||||
"""is_daemon_alive — PID 파일 stale 검출."""
|
||||
|
||||
def setUp(self):
|
||||
fill_watcher._reset_for_test()
|
||||
self.addCleanup(fill_watcher._reset_for_test)
|
||||
|
||||
def test_no_pid_file(self):
|
||||
self.assertFalse(fill_watcher.is_daemon_alive())
|
||||
|
||||
def test_invalid_pid_file(self):
|
||||
fill_watcher.PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
fill_watcher.PID_FILE.write_text('not_a_number', encoding='utf-8')
|
||||
self.assertFalse(fill_watcher.is_daemon_alive())
|
||||
|
||||
def test_stale_pid(self):
|
||||
# 절대 안 쓰일 큰 PID — Pid lookup 실패
|
||||
fill_watcher.PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
fill_watcher.PID_FILE.write_text('99999999', encoding='utf-8')
|
||||
self.assertFalse(fill_watcher.is_daemon_alive())
|
||||
|
||||
def test_alive_pid(self):
|
||||
# 현재 프로세스는 살아있음
|
||||
fill_watcher.PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
fill_watcher.PID_FILE.write_text(str(os.getpid()), encoding='utf-8')
|
||||
self.assertTrue(fill_watcher.is_daemon_alive())
|
||||
|
||||
|
||||
class FillWatcherSyncFromQueueTests(unittest.TestCase):
|
||||
"""sync_from_queue — 큐 → _tracked 양방향 동기화."""
|
||||
|
||||
def setUp(self):
|
||||
fill_watcher._reset_for_test()
|
||||
self.addCleanup(fill_watcher._reset_for_test)
|
||||
|
||||
def _entry(self, ord_no='100001', last_cntr=0):
|
||||
return {'ord_no': ord_no, 'account': '일반', 'side': 'BUY',
|
||||
'symbol': '005930', 'symbol_name': '삼성전자', 'order_qty': 10,
|
||||
'price': 75000, 'order_type': 'LIMIT', 'card_id': 'A7K3',
|
||||
'started_at': 1.0, 'last_cntr_qty': last_cntr}
|
||||
|
||||
def test_sync_adds_new_entries(self):
|
||||
fill_watcher._watcher.sync_from_queue([self._entry('100001'), self._entry('100002')])
|
||||
self.assertEqual(set(fill_watcher._peek_for_test().keys()), {'100001', '100002'})
|
||||
|
||||
def test_sync_removes_missing(self):
|
||||
fill_watcher._watcher.sync_from_queue([self._entry('100001'), self._entry('100002')])
|
||||
fill_watcher._watcher.sync_from_queue([self._entry('100001')])
|
||||
self.assertEqual(set(fill_watcher._peek_for_test().keys()), {'100001'})
|
||||
|
||||
def test_sync_preserves_progress(self):
|
||||
fill_watcher._watcher.sync_from_queue([self._entry('100001', last_cntr=3)])
|
||||
self.assertEqual(fill_watcher._peek_for_test()['100001'].last_cntr_qty, 3)
|
||||
|
||||
def test_snapshot_round_trip(self):
|
||||
fill_watcher._watcher.sync_from_queue([self._entry('100001', last_cntr=3)])
|
||||
snap = fill_watcher._watcher.snapshot_entries()
|
||||
self.assertEqual(len(snap), 1)
|
||||
self.assertEqual(snap[0]['ord_no'], '100001')
|
||||
self.assertEqual(snap[0]['last_cntr_qty'], 3)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,749 @@
|
||||
"""guards 단위테스트. 매매 가드의 모든 분기를 mock 데이터로 검증."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from orders import guards, ledger
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def t(h, m, s=0, day=6):
|
||||
return datetime(2026, 5, day, h, m, s, tzinfo=KST)
|
||||
|
||||
|
||||
# ---------- 계좌 ----------
|
||||
|
||||
class AccountTests(unittest.TestCase):
|
||||
def test_owner_accounts_allowed(self):
|
||||
self.assertTrue(guards.validate_account('일반').ok)
|
||||
self.assertTrue(guards.validate_account('ISA').ok)
|
||||
|
||||
def test_spouse_accounts_allowed(self):
|
||||
self.assertTrue(guards.validate_account('가희_일반').ok)
|
||||
self.assertTrue(guards.validate_account('가희_ISA').ok)
|
||||
|
||||
def test_unknown_account_rejected(self):
|
||||
r = guards.validate_account('해외')
|
||||
self.assertFalse(r.ok)
|
||||
self.assertEqual(r.code, 'ACCOUNT_NOT_WHITELISTED')
|
||||
|
||||
def test_is_spouse_account(self):
|
||||
self.assertTrue(guards.is_spouse_account('가희_일반'))
|
||||
self.assertTrue(guards.is_spouse_account('가희_ISA'))
|
||||
self.assertFalse(guards.is_spouse_account('일반'))
|
||||
self.assertFalse(guards.is_spouse_account('ISA'))
|
||||
|
||||
|
||||
# ---------- 시간대 ----------
|
||||
|
||||
class SessionTests(unittest.TestCase):
|
||||
def test_closed_before_pre(self):
|
||||
self.assertEqual(guards.session_at(t(7, 30)), 'CLOSED')
|
||||
|
||||
def test_nxt_pre_boundaries(self):
|
||||
self.assertEqual(guards.session_at(t(8, 0)), 'NXT_PRE')
|
||||
self.assertEqual(guards.session_at(t(8, 30)), 'NXT_PRE')
|
||||
self.assertEqual(guards.session_at(t(8, 59, 59)), 'NXT_PRE')
|
||||
|
||||
def test_krx_nxt_concurrent(self):
|
||||
self.assertEqual(guards.session_at(t(9, 0, 30)), 'KRX_NXT')
|
||||
self.assertEqual(guards.session_at(t(12, 0)), 'KRX_NXT')
|
||||
self.assertEqual(guards.session_at(t(15, 19, 59)), 'KRX_NXT')
|
||||
|
||||
def test_krx_close(self):
|
||||
self.assertEqual(guards.session_at(t(15, 20)), 'KRX_CLOSE')
|
||||
self.assertEqual(guards.session_at(t(15, 25)), 'KRX_CLOSE')
|
||||
self.assertEqual(guards.session_at(t(15, 29, 59)), 'KRX_CLOSE')
|
||||
|
||||
def test_nxt_after(self):
|
||||
self.assertEqual(guards.session_at(t(15, 30)), 'NXT_AFTER')
|
||||
self.assertEqual(guards.session_at(t(18, 0)), 'NXT_AFTER')
|
||||
self.assertEqual(guards.session_at(t(19, 59, 59)), 'NXT_AFTER')
|
||||
|
||||
def test_closed_after_after(self):
|
||||
self.assertEqual(guards.session_at(t(20, 0)), 'CLOSED')
|
||||
self.assertEqual(guards.session_at(t(21, 0)), 'CLOSED')
|
||||
|
||||
def test_gap_between_pre_and_regular_is_closed(self):
|
||||
self.assertEqual(guards.session_at(t(9, 0, 0)), 'CLOSED')
|
||||
|
||||
|
||||
# ---------- 거래시간 + NXT 매트릭스 ----------
|
||||
|
||||
class TradingHoursTests(unittest.TestCase):
|
||||
def test_holiday(self):
|
||||
r = guards.validate_trading_hours(t(10, 0), True, True)
|
||||
self.assertFalse(r.ok)
|
||||
self.assertEqual(r.code, 'HOLIDAY')
|
||||
|
||||
def test_closed(self):
|
||||
r = guards.validate_trading_hours(t(21, 0), False, True)
|
||||
self.assertFalse(r.ok)
|
||||
self.assertEqual(r.code, 'OUTSIDE_HOURS')
|
||||
|
||||
def test_nxt_pre_eligible(self):
|
||||
self.assertTrue(guards.validate_trading_hours(t(8, 30), False, True).ok)
|
||||
|
||||
def test_nxt_pre_not_eligible(self):
|
||||
r = guards.validate_trading_hours(t(8, 30), False, False)
|
||||
self.assertFalse(r.ok)
|
||||
self.assertEqual(r.code, 'NXT_NOT_ELIGIBLE')
|
||||
|
||||
def test_nxt_after_not_eligible(self):
|
||||
r = guards.validate_trading_hours(t(18, 0), False, False)
|
||||
self.assertEqual(r.code, 'NXT_NOT_ELIGIBLE')
|
||||
|
||||
def test_krx_close_no_nxt_required(self):
|
||||
self.assertTrue(guards.validate_trading_hours(t(15, 25), False, False).ok)
|
||||
|
||||
|
||||
# ---------- 라우팅 ----------
|
||||
|
||||
class RoutingTests(unittest.TestCase):
|
||||
def test_krx_nxt_with_eligible(self):
|
||||
self.assertEqual(guards.determine_routing(t(10, 0), True, None), '_AL')
|
||||
|
||||
def test_krx_nxt_without_eligible(self):
|
||||
self.assertEqual(guards.determine_routing(t(10, 0), False, None), '')
|
||||
|
||||
def test_nxt_pre(self):
|
||||
self.assertEqual(guards.determine_routing(t(8, 30), True, None), '_NX')
|
||||
|
||||
def test_nxt_after(self):
|
||||
self.assertEqual(guards.determine_routing(t(18, 0), True, None), '_NX')
|
||||
|
||||
def test_krx_close(self):
|
||||
self.assertEqual(guards.determine_routing(t(15, 25), False, None), '')
|
||||
|
||||
def test_force_options(self):
|
||||
self.assertEqual(guards.determine_routing(t(10, 0), True, 'AL'), '_AL')
|
||||
self.assertEqual(guards.determine_routing(t(10, 0), True, 'NX'), '_NX')
|
||||
self.assertEqual(guards.determine_routing(t(10, 0), True, 'KRX'), '')
|
||||
|
||||
def test_invalid_force(self):
|
||||
with self.assertRaises(ValueError):
|
||||
guards.determine_routing(t(10, 0), True, 'XYZ')
|
||||
|
||||
def test_closed_session_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
guards.determine_routing(t(21, 0), True, None)
|
||||
|
||||
|
||||
# ---------- 가격 가드 ----------
|
||||
|
||||
class PriceBandTests(unittest.TestCase):
|
||||
def test_within_band(self):
|
||||
self.assertTrue(guards.validate_price_band('BUY', 75000, 97500, 52500).ok)
|
||||
self.assertTrue(guards.validate_price_band('SELL', 75000, 97500, 52500).ok)
|
||||
|
||||
def test_at_upper_inclusive(self):
|
||||
self.assertTrue(guards.validate_price_band('BUY', 97500, 97500, 52500).ok)
|
||||
|
||||
def test_at_lower_inclusive(self):
|
||||
self.assertTrue(guards.validate_price_band('SELL', 52500, 97500, 52500).ok)
|
||||
|
||||
def test_above_upper(self):
|
||||
r = guards.validate_price_band('BUY', 100000, 97500, 52500)
|
||||
self.assertEqual(r.code, 'PRICE_ABOVE_UPPER')
|
||||
|
||||
def test_below_lower(self):
|
||||
r = guards.validate_price_band('SELL', 50000, 97500, 52500)
|
||||
self.assertEqual(r.code, 'PRICE_BELOW_LOWER')
|
||||
|
||||
|
||||
# ---------- 잔고 / 보유 / 정지 / VI ----------
|
||||
|
||||
class BalancePositionTests(unittest.TestCase):
|
||||
def test_balance_sufficient(self):
|
||||
self.assertTrue(guards.validate_balance_for_buy(5, 75000, 1_000_000).ok)
|
||||
|
||||
def test_balance_exact(self):
|
||||
self.assertTrue(guards.validate_balance_for_buy(5, 75000, 375_000).ok)
|
||||
|
||||
def test_balance_insufficient(self):
|
||||
r = guards.validate_balance_for_buy(5, 75000, 100)
|
||||
self.assertEqual(r.code, 'INSUFFICIENT_BALANCE')
|
||||
|
||||
def test_position_sufficient(self):
|
||||
self.assertTrue(guards.validate_position_for_sell(5, 100).ok)
|
||||
|
||||
def test_position_exact(self):
|
||||
self.assertTrue(guards.validate_position_for_sell(5, 5).ok)
|
||||
|
||||
def test_position_insufficient(self):
|
||||
r = guards.validate_position_for_sell(5, 3)
|
||||
self.assertEqual(r.code, 'INSUFFICIENT_POSITION')
|
||||
|
||||
|
||||
class MarketOrderSessionTests(unittest.TestCase):
|
||||
def test_market_in_krx_regular_ok(self):
|
||||
self.assertTrue(guards.validate_market_order_session(t(10, 0), 'MARKET').ok)
|
||||
|
||||
def test_market_in_nxt_pre_blocked(self):
|
||||
r = guards.validate_market_order_session(t(8, 30), 'MARKET')
|
||||
self.assertFalse(r.ok)
|
||||
self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_NXT')
|
||||
|
||||
def test_market_in_nxt_after_blocked(self):
|
||||
r = guards.validate_market_order_session(t(18, 0), 'MARKET')
|
||||
self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_NXT')
|
||||
|
||||
def test_market_in_auction_blocked(self):
|
||||
r = guards.validate_market_order_session(t(15, 25), 'MARKET')
|
||||
self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_AUCTION')
|
||||
|
||||
def test_limit_unaffected(self):
|
||||
for hour, minute in [(8, 30), (10, 0), (15, 25), (18, 0)]:
|
||||
with self.subTest(time=(hour, minute)):
|
||||
self.assertTrue(guards.validate_market_order_session(t(hour, minute), 'LIMIT').ok)
|
||||
|
||||
def test_aggressive_limit_unaffected(self):
|
||||
for hour, minute in [(8, 30), (15, 25), (18, 0)]:
|
||||
self.assertTrue(guards.validate_market_order_session(t(hour, minute), 'AGGRESSIVE_LIMIT').ok)
|
||||
|
||||
|
||||
class HaltViTests(unittest.TestCase):
|
||||
def test_normal(self):
|
||||
self.assertTrue(guards.validate_halt_vi(False, False).ok)
|
||||
|
||||
def test_halt(self):
|
||||
r = guards.validate_halt_vi(True, False)
|
||||
self.assertEqual(r.code, 'TRADING_HALT')
|
||||
|
||||
def test_vi(self):
|
||||
r = guards.validate_halt_vi(False, True)
|
||||
self.assertEqual(r.code, 'VI')
|
||||
|
||||
|
||||
# ---------- 딜레이 ----------
|
||||
|
||||
class DelayTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.now = t(10, 0)
|
||||
|
||||
def test_global_no_history(self):
|
||||
with mock.patch.object(ledger, 'last_terminal_event', return_value=None):
|
||||
self.assertTrue(guards.validate_delay_between_orders(self.now).ok)
|
||||
|
||||
def test_global_within_cooldown(self):
|
||||
recent = (self.now - timedelta(seconds=30)).isoformat()
|
||||
with mock.patch.object(ledger, 'last_terminal_event',
|
||||
return_value={'ts': recent, 'event': 'filled'}):
|
||||
r = guards.validate_delay_between_orders(self.now)
|
||||
self.assertEqual(r.code, 'COOLDOWN_GLOBAL')
|
||||
|
||||
def test_global_after_cooldown(self):
|
||||
old = (self.now - timedelta(seconds=120)).isoformat()
|
||||
with mock.patch.object(ledger, 'last_terminal_event',
|
||||
return_value={'ts': old, 'event': 'filled'}):
|
||||
self.assertTrue(guards.validate_delay_between_orders(self.now).ok)
|
||||
|
||||
def test_same_symbol_within_3min(self):
|
||||
recent = (self.now - timedelta(seconds=60)).isoformat()
|
||||
with mock.patch.object(ledger, 'last_event_for_symbol',
|
||||
return_value={'ts': recent, 'event': 'filled',
|
||||
'payload': {'account': '일반', 'symbol': '005930'}}):
|
||||
r = guards.validate_delay_same_symbol(self.now, '일반', '005930')
|
||||
self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL')
|
||||
|
||||
def test_same_symbol_after_3min(self):
|
||||
old = (self.now - timedelta(seconds=200)).isoformat()
|
||||
with mock.patch.object(ledger, 'last_event_for_symbol',
|
||||
return_value={'ts': old, 'event': 'filled',
|
||||
'payload': {'account': '일반', 'symbol': '005930'}}):
|
||||
self.assertTrue(guards.validate_delay_same_symbol(self.now, '일반', '005930').ok)
|
||||
|
||||
def test_same_symbol_no_history(self):
|
||||
with mock.patch.object(ledger, 'last_event_for_symbol', return_value=None):
|
||||
self.assertTrue(guards.validate_delay_same_symbol(self.now, '일반', '005930').ok)
|
||||
|
||||
def test_same_symbol_blocked_by_submitted(self):
|
||||
"""체결 폴링 미구현 상태에서도 submitted 만으로 동일 종목 가드 작동."""
|
||||
recent = (self.now - timedelta(seconds=30)).isoformat()
|
||||
with mock.patch.object(ledger, 'last_event_for_symbol',
|
||||
return_value={'ts': recent, 'event': 'submitted',
|
||||
'payload': {'account': '일반', 'symbol': '005930'}}):
|
||||
r = guards.validate_delay_same_symbol(self.now, '일반', '005930')
|
||||
self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL')
|
||||
|
||||
def test_same_symbol_only_counts_post_submit_events(self):
|
||||
"""rejected/canceled/expired/failed 는 키움 접수 전이라 동일 종목 가드 대상 아님."""
|
||||
captured = {}
|
||||
|
||||
def fake_last(account, symbol, events=()):
|
||||
captured['events'] = events
|
||||
return None
|
||||
|
||||
with mock.patch.object(ledger, 'last_event_for_symbol', side_effect=fake_last):
|
||||
self.assertTrue(guards.validate_delay_same_symbol(self.now, '일반', '005930').ok)
|
||||
self.assertEqual(captured['events'], ('submitted', 'filled', 'partial'))
|
||||
|
||||
|
||||
# ---------- 키움 진실 소스 가드 (kt00007) ----------
|
||||
|
||||
class BrokerDelayTests(unittest.TestCase):
|
||||
"""validate_delay_same_symbol_via_broker — NETWORK 사각·키움앱 직접 매매까지 잡는 가드."""
|
||||
|
||||
def setUp(self):
|
||||
self.now = t(10, 0)
|
||||
|
||||
def _exec(self, code: str, seconds_ago: int, comm_src: str = 'REST API') -> dict:
|
||||
ord_tm = (self.now - timedelta(seconds=seconds_ago)).strftime('%H:%M:%S')
|
||||
return {'code': code, 'ord_tm': ord_tm, 'comm_src': comm_src}
|
||||
|
||||
def test_no_executions_passes(self):
|
||||
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', [], None)
|
||||
self.assertTrue(r.ok)
|
||||
|
||||
def test_query_failed_blocks_conservatively(self):
|
||||
"""키움 조회 자체 실패 시 보수적 차단 — 정확도 우선."""
|
||||
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', None, 'TimeoutError(...)')
|
||||
self.assertEqual(r.code, 'BROKER_QUERY_FAILED')
|
||||
|
||||
def test_recent_execution_blocks(self):
|
||||
executions = [self._exec('005930', seconds_ago=60)]
|
||||
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
|
||||
self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL_BROKER')
|
||||
|
||||
def test_old_execution_passes(self):
|
||||
executions = [self._exec('005930', seconds_ago=200)]
|
||||
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
|
||||
self.assertTrue(r.ok)
|
||||
|
||||
def test_different_symbol_ignored(self):
|
||||
executions = [self._exec('000660', seconds_ago=30)]
|
||||
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
|
||||
self.assertTrue(r.ok)
|
||||
|
||||
def test_external_app_execution_also_blocks(self):
|
||||
"""사용자가 키움앱(영웅문)으로 직접 매매한 것도 가드 대상."""
|
||||
executions = [self._exec('005930', seconds_ago=30, comm_src='영웅문S#')]
|
||||
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
|
||||
self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL_BROKER')
|
||||
self.assertIn('영웅문', r.message)
|
||||
|
||||
def test_picks_latest_among_multiple(self):
|
||||
executions = [
|
||||
self._exec('005930', seconds_ago=300, comm_src='영웅문S#'),
|
||||
self._exec('005930', seconds_ago=60, comm_src='REST API'),
|
||||
]
|
||||
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
|
||||
self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL_BROKER')
|
||||
|
||||
def test_malformed_ord_tm_skipped(self):
|
||||
"""ord_tm 파싱 실패 행은 무시. 다른 정상 행이 없으면 통과."""
|
||||
executions = [{'code': '005930', 'ord_tm': '', 'comm_src': 'REST API'}]
|
||||
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
|
||||
self.assertTrue(r.ok)
|
||||
|
||||
def test_global_cooldown_only_counts_post_submit_events(self):
|
||||
"""rejected/canceled/expired/failed 는 키움 접수 전이라 쿨다운 대상 아님."""
|
||||
captured = {}
|
||||
|
||||
def fake_last(events=()):
|
||||
captured['events'] = events
|
||||
return None
|
||||
|
||||
with mock.patch.object(ledger, 'last_terminal_event', side_effect=fake_last):
|
||||
self.assertTrue(guards.validate_delay_between_orders(self.now).ok)
|
||||
self.assertEqual(captured['events'], ('submitted', 'filled', 'partial'))
|
||||
|
||||
def test_global_cooldown_triggered_by_submitted(self):
|
||||
"""submitted (실주문 접수) 도 쿨다운 트리거."""
|
||||
recent = (self.now - timedelta(seconds=30)).isoformat()
|
||||
with mock.patch.object(ledger, 'last_terminal_event',
|
||||
return_value={'ts': recent, 'event': 'submitted'}):
|
||||
r = guards.validate_delay_between_orders(self.now)
|
||||
self.assertEqual(r.code, 'COOLDOWN_GLOBAL')
|
||||
|
||||
|
||||
# ---------- 자연어 시장가 분류 ----------
|
||||
|
||||
class IntentTests(unittest.TestCase):
|
||||
def test_market_keyword(self):
|
||||
self.assertEqual(guards.classify_order_intent('삼성 5주 시장가'), 'MARKET')
|
||||
|
||||
def test_aggressive_keywords(self):
|
||||
for kw in ('지금 바로 사줘', '즉시 매수', '빨리 사', '당장 사줘'):
|
||||
self.assertEqual(guards.classify_order_intent(kw), 'AGGRESSIVE_LIMIT')
|
||||
|
||||
def test_default_limit(self):
|
||||
self.assertEqual(guards.classify_order_intent('삼성 5주 75000원'), 'LIMIT')
|
||||
self.assertEqual(guards.classify_order_intent(''), 'LIMIT')
|
||||
|
||||
def test_market_takes_priority_over_aggressive(self):
|
||||
# 시장가 키워드가 우선 — "지금 바로 시장가" 같은 경우
|
||||
self.assertEqual(guards.classify_order_intent('지금 바로 시장가로'), 'MARKET')
|
||||
|
||||
|
||||
# ---------- 호가단위 + 공격적 지정가 ----------
|
||||
|
||||
class TickSizeTests(unittest.TestCase):
|
||||
def test_ranges(self):
|
||||
cases = [(1500, 1), (3000, 5), (15000, 10), (45000, 50),
|
||||
(100000, 100), (300000, 500), (700000, 1000)]
|
||||
for price, expected in cases:
|
||||
with self.subTest(price=price):
|
||||
self.assertEqual(guards.tick_size(price), expected)
|
||||
|
||||
|
||||
class AggressivePriceTests(unittest.TestCase):
|
||||
def test_buy_one_tick_above(self):
|
||||
ob = {'asks': [{'price': 75100, 'qty': 1000}], 'bids': [{'price': 75000, 'qty': 1000}]}
|
||||
r = guards.aggressive_limit_price('BUY', ob)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['price'], 75200)
|
||||
self.assertEqual(r['source'], 'orderbook')
|
||||
|
||||
def test_sell_one_tick_below(self):
|
||||
ob = {'asks': [{'price': 75100, 'qty': 1000}], 'bids': [{'price': 75000, 'qty': 1000}]}
|
||||
r = guards.aggressive_limit_price('SELL', ob)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['price'], 74900)
|
||||
|
||||
def test_buy_at_low_price_band(self):
|
||||
# 1500원대 → tick=1
|
||||
ob = {'asks': [{'price': 1500, 'qty': 1000}], 'bids': [{'price': 1499, 'qty': 1000}]}
|
||||
r = guards.aggressive_limit_price('BUY', ob)
|
||||
self.assertEqual(r['price'], 1501)
|
||||
|
||||
def test_no_orderbook_uses_fallback_price(self):
|
||||
r = guards.aggressive_limit_price('BUY', None, fallback_price=75050)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['price'], 75150) # 75050 + 100tick
|
||||
self.assertEqual(r['source'], 'fallback')
|
||||
|
||||
def test_empty_orderbook_uses_fallback_price(self):
|
||||
r = guards.aggressive_limit_price('SELL', {'asks': [], 'bids': []}, fallback_price=75050)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['price'], 74950)
|
||||
self.assertEqual(r['source'], 'fallback')
|
||||
|
||||
def test_no_orderbook_no_fallback_rejects(self):
|
||||
r = guards.aggressive_limit_price('BUY', None)
|
||||
self.assertFalse(r['ok'])
|
||||
self.assertEqual(r['code'], 'NO_ORDERBOOK')
|
||||
|
||||
def test_orderbook_takes_priority_over_fallback(self):
|
||||
ob = {'asks': [{'price': 75100, 'qty': 1000}], 'bids': [{'price': 75000, 'qty': 1000}]}
|
||||
r = guards.aggressive_limit_price('BUY', ob, fallback_price=80000)
|
||||
self.assertEqual(r['source'], 'orderbook')
|
||||
self.assertEqual(r['price'], 75200)
|
||||
|
||||
|
||||
# ---------- 시장가 슬리피지 추정 ----------
|
||||
|
||||
class MarketEstimateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ob = {
|
||||
'asks': [{'price': 75100, 'qty': 1000}, {'price': 75200, 'qty': 500}],
|
||||
'bids': [{'price': 75000, 'qty': 800}, {'price': 74900, 'qty': 1200}],
|
||||
}
|
||||
|
||||
def test_buy_within_first_level(self):
|
||||
est = guards.estimate_market_fill('BUY', 5, self.ob)
|
||||
self.assertEqual(est['avg_fill'], 75100)
|
||||
self.assertEqual(est['slippage_pct'], 0.0)
|
||||
|
||||
def test_buy_across_levels(self):
|
||||
est = guards.estimate_market_fill('BUY', 1500, self.ob)
|
||||
# 1000 × 75100 + 500 × 75200 = 112,700,000 → avg 75133
|
||||
self.assertEqual(est['total_won'], 112_700_000)
|
||||
self.assertEqual(est['avg_fill'], 75133)
|
||||
self.assertGreater(est['slippage_pct'], 0)
|
||||
|
||||
def test_sell_first_level(self):
|
||||
est = guards.estimate_market_fill('SELL', 5, self.ob)
|
||||
self.assertEqual(est['avg_fill'], 75000)
|
||||
|
||||
def test_buy_exceeds_orderbook_depth(self):
|
||||
# depth=3 default 지만 ob asks 2단계뿐 → 5000주 매수면 마지막 호가가 fallback
|
||||
est = guards.estimate_market_fill('BUY', 5000, self.ob)
|
||||
self.assertGreater(est['total_won'], 0)
|
||||
self.assertGreater(est['avg_fill'], 0)
|
||||
|
||||
|
||||
# ---------- 통합 검증 ----------
|
||||
|
||||
class IntegrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.now = t(10, 0)
|
||||
self.ob = {
|
||||
'asks': [{'price': 75100, 'qty': 1250}, {'price': 75200, 'qty': 890}],
|
||||
'bids': [{'price': 75000, 'qty': 800}, {'price': 74900, 'qty': 1200}],
|
||||
}
|
||||
self.md_buy = {
|
||||
'now': self.now, 'is_holiday': False, 'nxt_eligible': True,
|
||||
'current_price': 75000, 'prev_close': 74800,
|
||||
'upper_limit': 97500, 'lower_limit': 52500,
|
||||
'halt': False, 'vi': False, 'orderbook': self.ob,
|
||||
'balance_d2': 1_000_000,
|
||||
'broker_executions': [], # broker 가드 통과용 (정상=빈 리스트)
|
||||
'broker_query_error': None,
|
||||
}
|
||||
self.md_sell = dict(self.md_buy, position_qty=100)
|
||||
self.req_buy = {'account': '일반', 'side': 'BUY', 'symbol': '005930',
|
||||
'qty': 5, 'order_type': 'LIMIT', 'price': 75000}
|
||||
self.req_sell = {'account': 'ISA', 'side': 'SELL', 'symbol': '005930',
|
||||
'qty': 5, 'order_type': 'LIMIT', 'price': 75000}
|
||||
|
||||
def _no_history(self):
|
||||
return mock.patch.multiple(
|
||||
ledger,
|
||||
last_terminal_event=mock.MagicMock(return_value=None),
|
||||
last_event_for_symbol=mock.MagicMock(return_value=None),
|
||||
)
|
||||
|
||||
def test_normal_buy(self):
|
||||
with self._no_history():
|
||||
self.assertTrue(guards.validate_request(self.req_buy, self.md_buy).ok)
|
||||
|
||||
def test_normal_sell(self):
|
||||
with self._no_history():
|
||||
self.assertTrue(guards.validate_request(self.req_sell, self.md_sell).ok)
|
||||
|
||||
def test_account_rejected(self):
|
||||
with self._no_history():
|
||||
req = dict(self.req_buy, account='해외')
|
||||
r = guards.validate_request(req, self.md_buy)
|
||||
self.assertEqual(r.code, 'ACCOUNT_NOT_WHITELISTED')
|
||||
|
||||
def test_invalid_side(self):
|
||||
with self._no_history():
|
||||
req = dict(self.req_buy, side='HOLD')
|
||||
r = guards.validate_request(req, self.md_buy)
|
||||
self.assertEqual(r.code, 'INVALID_SIDE')
|
||||
|
||||
def test_invalid_order_type(self):
|
||||
with self._no_history():
|
||||
req = dict(self.req_buy, order_type='UNKNOWN')
|
||||
r = guards.validate_request(req, self.md_buy)
|
||||
self.assertEqual(r.code, 'INVALID_ORDER_TYPE')
|
||||
|
||||
def test_holiday_blocks(self):
|
||||
with self._no_history():
|
||||
md = dict(self.md_buy, is_holiday=True)
|
||||
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'HOLIDAY')
|
||||
|
||||
def test_outside_hours(self):
|
||||
with self._no_history():
|
||||
md = dict(self.md_buy, now=t(21, 0))
|
||||
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'OUTSIDE_HOURS')
|
||||
|
||||
def test_halt(self):
|
||||
with self._no_history():
|
||||
md = dict(self.md_buy, halt=True)
|
||||
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'TRADING_HALT')
|
||||
|
||||
def test_vi(self):
|
||||
with self._no_history():
|
||||
md = dict(self.md_buy, vi=True)
|
||||
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'VI')
|
||||
|
||||
def test_price_above_upper(self):
|
||||
with self._no_history():
|
||||
req = dict(self.req_buy, price=100_000)
|
||||
self.assertEqual(guards.validate_request(req, self.md_buy).code, 'PRICE_ABOVE_UPPER')
|
||||
|
||||
def test_insufficient_balance(self):
|
||||
with self._no_history():
|
||||
md = dict(self.md_buy, balance_d2=100)
|
||||
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'INSUFFICIENT_BALANCE')
|
||||
|
||||
def test_insufficient_position(self):
|
||||
with self._no_history():
|
||||
md = dict(self.md_sell, position_qty=2)
|
||||
self.assertEqual(guards.validate_request(self.req_sell, md).code, 'INSUFFICIENT_POSITION')
|
||||
|
||||
def test_market_buy_uses_orderbook(self):
|
||||
with self._no_history():
|
||||
req = dict(self.req_buy, order_type='MARKET')
|
||||
req.pop('price', None)
|
||||
self.assertTrue(guards.validate_request(req, self.md_buy).ok)
|
||||
|
||||
def test_market_buy_in_nxt_blocked(self):
|
||||
with self._no_history():
|
||||
req = dict(self.req_buy, order_type='MARKET'); req.pop('price', None)
|
||||
md = dict(self.md_buy, now=t(8, 30))
|
||||
r = guards.validate_request(req, md)
|
||||
self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_NXT')
|
||||
|
||||
def test_market_buy_in_auction_blocked(self):
|
||||
with self._no_history():
|
||||
req = dict(self.req_buy, order_type='MARKET'); req.pop('price', None)
|
||||
md = dict(self.md_buy, now=t(15, 25))
|
||||
r = guards.validate_request(req, md)
|
||||
self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_AUCTION')
|
||||
|
||||
def test_nxt_time_not_eligible(self):
|
||||
with self._no_history():
|
||||
md = dict(self.md_buy, now=t(8, 30), nxt_eligible=False)
|
||||
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'NXT_NOT_ELIGIBLE')
|
||||
|
||||
def test_global_cooldown_blocks_new_order(self):
|
||||
recent = (self.now - timedelta(seconds=30)).isoformat()
|
||||
with mock.patch.object(ledger, 'last_terminal_event',
|
||||
return_value={'ts': recent, 'event': 'filled'}), \
|
||||
mock.patch.object(ledger, 'last_event_for_symbol', return_value=None):
|
||||
self.assertEqual(guards.validate_request(self.req_buy, self.md_buy).code, 'COOLDOWN_GLOBAL')
|
||||
|
||||
def test_aggressive_limit_buy_uses_orderbook(self):
|
||||
with self._no_history():
|
||||
req = dict(self.req_buy, order_type='AGGRESSIVE_LIMIT'); req.pop('price', None)
|
||||
self.assertTrue(guards.validate_request(req, self.md_buy).ok)
|
||||
|
||||
def test_aggressive_limit_buy_fallback_when_orderbook_missing(self):
|
||||
"""호가창 비어도 current_price fallback 으로 잔고 가드까지 통과."""
|
||||
with self._no_history():
|
||||
req = dict(self.req_buy, order_type='AGGRESSIVE_LIMIT'); req.pop('price', None)
|
||||
md = dict(self.md_buy, orderbook=None, current_price=75000)
|
||||
self.assertTrue(guards.validate_request(req, md).ok)
|
||||
|
||||
def test_aggressive_limit_buy_no_orderbook_no_quote_rejects(self):
|
||||
with self._no_history():
|
||||
req = dict(self.req_buy, order_type='AGGRESSIVE_LIMIT'); req.pop('price', None)
|
||||
md = dict(self.md_buy, orderbook=None, current_price=0)
|
||||
r = guards.validate_request(req, md)
|
||||
self.assertEqual(r.code, 'NO_ORDERBOOK')
|
||||
|
||||
def test_same_symbol_cooldown_blocks(self):
|
||||
recent = (self.now - timedelta(seconds=60)).isoformat()
|
||||
with mock.patch.object(ledger, 'last_terminal_event', return_value=None), \
|
||||
mock.patch.object(ledger, 'last_event_for_symbol',
|
||||
return_value={'ts': recent, 'event': 'filled',
|
||||
'payload': {'account': '일반', 'symbol': '005930'}}):
|
||||
self.assertEqual(guards.validate_request(self.req_buy, self.md_buy).code,
|
||||
'COOLDOWN_SAME_SYMBOL')
|
||||
|
||||
|
||||
# ---------- 예산 → 수량 환산 ----------
|
||||
|
||||
class BudgetConversionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.orderbook = {
|
||||
'asks': [{'price': 75100, 'qty': 200}, {'price': 75200, 'qty': 150}],
|
||||
'bids': [{'price': 75000, 'qty': 180}, {'price': 74900, 'qty': 220}],
|
||||
}
|
||||
|
||||
def test_buy_uses_ask1_floor_plus_one_under_threshold(self):
|
||||
# 1,000,000 / 75,100 = 13.31... → floor 13 + bump → 14주, 초과액 51,400원
|
||||
# 75,100원 ≤ 30만원 → BUY +1 정책 적용
|
||||
r = guards.convert_budget_to_qty('BUY', 1_000_000, self.orderbook)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['qty'], 14)
|
||||
self.assertEqual(r['ref_price'], 75100)
|
||||
self.assertTrue(r['bumped'])
|
||||
self.assertEqual(r['remainder'], 1_000_000 - 14 * 75100) # 음수
|
||||
self.assertLess(r['remainder'], 0)
|
||||
|
||||
def test_sell_uses_bid1_floor(self):
|
||||
# SELL 은 ref_price 가 30만원 이하여도 bump 안 함
|
||||
# 500,000 / 75,000 = 6.66... → 6주
|
||||
r = guards.convert_budget_to_qty('SELL', 500_000, self.orderbook)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['qty'], 6)
|
||||
self.assertEqual(r['ref_price'], 75000)
|
||||
self.assertFalse(r['bumped'])
|
||||
self.assertEqual(r['remainder'], 500_000 - 6 * 75000)
|
||||
|
||||
def test_budget_too_small_rejects(self):
|
||||
r = guards.convert_budget_to_qty('BUY', 50_000, self.orderbook)
|
||||
self.assertFalse(r['ok'])
|
||||
self.assertEqual(r['code'], 'BUDGET_TOO_SMALL')
|
||||
|
||||
def test_budget_zero_rejects(self):
|
||||
r = guards.convert_budget_to_qty('BUY', 0, self.orderbook)
|
||||
self.assertFalse(r['ok'])
|
||||
self.assertEqual(r['code'], 'BUDGET_INVALID')
|
||||
|
||||
def test_budget_negative_rejects(self):
|
||||
r = guards.convert_budget_to_qty('BUY', -1000, self.orderbook)
|
||||
self.assertFalse(r['ok'])
|
||||
self.assertEqual(r['code'], 'BUDGET_INVALID')
|
||||
|
||||
def test_no_orderbook_no_fallback_rejects(self):
|
||||
r = guards.convert_budget_to_qty('BUY', 1_000_000, None)
|
||||
self.assertFalse(r['ok'])
|
||||
self.assertEqual(r['code'], 'NO_ORDERBOOK')
|
||||
|
||||
def test_empty_asks_no_fallback_rejects(self):
|
||||
ob = {'asks': [], 'bids': self.orderbook['bids']}
|
||||
r = guards.convert_budget_to_qty('BUY', 1_000_000, ob)
|
||||
self.assertFalse(r['ok'])
|
||||
self.assertEqual(r['code'], 'NO_ORDERBOOK')
|
||||
|
||||
def test_no_orderbook_uses_fallback_price(self):
|
||||
# fallback 도 ≤ 30만원 이면 bump 적용
|
||||
r = guards.convert_budget_to_qty('BUY', 1_000_000, None, fallback_price=75050)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['qty'], 14) # floor 13 + bump
|
||||
self.assertEqual(r['ref_price'], 75050)
|
||||
self.assertEqual(r['source'], 'fallback')
|
||||
self.assertTrue(r['bumped'])
|
||||
|
||||
def test_empty_asks_uses_fallback_price(self):
|
||||
ob = {'asks': [], 'bids': self.orderbook['bids']}
|
||||
r = guards.convert_budget_to_qty('BUY', 1_000_000, ob, fallback_price=75050)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['source'], 'fallback')
|
||||
self.assertEqual(r['ref_price'], 75050)
|
||||
|
||||
def test_orderbook_takes_priority_over_fallback(self):
|
||||
r = guards.convert_budget_to_qty('BUY', 1_000_000, self.orderbook, fallback_price=80000)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['source'], 'orderbook')
|
||||
self.assertEqual(r['ref_price'], 75100)
|
||||
|
||||
def test_fallback_budget_too_small(self):
|
||||
r = guards.convert_budget_to_qty('BUY', 50_000, None, fallback_price=75050)
|
||||
self.assertFalse(r['ok'])
|
||||
self.assertEqual(r['code'], 'BUDGET_TOO_SMALL')
|
||||
self.assertIn('현재가', r['message'])
|
||||
|
||||
def test_exact_multiple_no_remainder(self):
|
||||
# 75,100 × 10 = 751,000 — remainder=0 이면 bump 안 함
|
||||
r = guards.convert_budget_to_qty('BUY', 751_000, self.orderbook)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['qty'], 10)
|
||||
self.assertEqual(r['remainder'], 0)
|
||||
self.assertFalse(r['bumped'])
|
||||
|
||||
def test_just_below_one_share(self):
|
||||
r = guards.convert_budget_to_qty('BUY', 75_099, self.orderbook)
|
||||
self.assertFalse(r['ok'])
|
||||
self.assertEqual(r['code'], 'BUDGET_TOO_SMALL')
|
||||
|
||||
def test_exactly_one_share(self):
|
||||
# remainder=0 이면 bump 안 함
|
||||
r = guards.convert_budget_to_qty('BUY', 75_100, self.orderbook)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['qty'], 1)
|
||||
self.assertEqual(r['remainder'], 0)
|
||||
self.assertFalse(r['bumped'])
|
||||
|
||||
def test_buy_no_bump_when_ref_price_exactly_at_threshold(self):
|
||||
# ref_price 정확히 30만원 → bump 적용 (≤ 경계 inclusive)
|
||||
ob = {'asks': [{'price': 300_000, 'qty': 50}],
|
||||
'bids': [{'price': 299_500, 'qty': 50}]}
|
||||
r = guards.convert_budget_to_qty('BUY', 1_000_000, ob)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['qty'], 4) # floor 3 + bump
|
||||
self.assertTrue(r['bumped'])
|
||||
|
||||
def test_buy_no_bump_when_ref_price_above_threshold(self):
|
||||
# ref_price 30만원 초과 → bump 미적용
|
||||
ob = {'asks': [{'price': 300_001, 'qty': 50}],
|
||||
'bids': [{'price': 299_500, 'qty': 50}]}
|
||||
r = guards.convert_budget_to_qty('BUY', 1_000_000, ob)
|
||||
self.assertTrue(r['ok'])
|
||||
self.assertEqual(r['qty'], 3) # floor 그대로
|
||||
self.assertFalse(r['bumped'])
|
||||
self.assertGreater(r['remainder'], 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,328 @@
|
||||
"""handler.amend_trade / cancel_active_card 회귀 테스트.
|
||||
|
||||
활성 카드 머지 → 가드 재실행 → PIN 재발급 흐름 검증.
|
||||
0 입력 시 cancel 위임, ambiguous 입력 거부, account/symbol/side 변경 불가.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from orders import handler, ledger, pin
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def _md(orderbook=None):
|
||||
return {
|
||||
'now': datetime(2026, 5, 8, 11, 30, tzinfo=KST),
|
||||
'is_holiday': False,
|
||||
'nxt_eligible': True,
|
||||
'current_price': 75050,
|
||||
'prev_close': 75000,
|
||||
'upper_limit': 100000,
|
||||
'lower_limit': 50000,
|
||||
'halt': False,
|
||||
'vi': False,
|
||||
'orderbook': orderbook if orderbook is not None else {
|
||||
'asks': [{'price': 75100, 'qty': 200}, {'price': 75200, 'qty': 150}],
|
||||
'bids': [{'price': 75000, 'qty': 180}, {'price': 74900, 'qty': 220}],
|
||||
},
|
||||
'broker_executions': [],
|
||||
'broker_query_error': None,
|
||||
'balance_d2': 5_000_000,
|
||||
}
|
||||
|
||||
|
||||
def _active_card(payload=None, expired=False, consumed=False):
|
||||
p = mock.MagicMock(spec=pin.PendingCard)
|
||||
p.card_id = 'A7K3'
|
||||
p.pin = '1234'
|
||||
p.account_label = '일반'
|
||||
p.payload = payload or {
|
||||
'account': '일반', 'side': 'BUY', 'symbol': '005930', 'symbol_name': '삼성전자',
|
||||
'qty': 14, 'price': None, 'order_type': 'MARKET', 'routing_suffix': '_AL',
|
||||
}
|
||||
p.consumed = consumed
|
||||
p.is_expired = mock.MagicMock(return_value=expired)
|
||||
return p
|
||||
|
||||
|
||||
class HandlerAmendTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.collect = mock.patch.object(handler.datasource, 'collect_market_data',
|
||||
return_value=_md()).start()
|
||||
mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None).start()
|
||||
mock.patch.object(handler, '_sweep_expired_and_notify', return_value=None).start()
|
||||
mock.patch.object(handler.ledger, 'append', return_value=None).start()
|
||||
mock.patch.object(ledger, 'last_terminal_event', return_value=None).start()
|
||||
mock.patch.object(ledger, 'last_event_for_symbol', return_value=None).start()
|
||||
# 활성 카드 mock — peek 으로 반환
|
||||
self.active = _active_card()
|
||||
mock.patch.object(handler._pin_store, 'peek', return_value=self.active).start()
|
||||
# amend 호출 시 새 PendingCard 반환
|
||||
self.amended_pending = mock.MagicMock()
|
||||
self.amended_pending.card_id = 'A7K3'
|
||||
self.amended_pending.pin = '5678'
|
||||
self.amended_pending.expiry_seconds = 120
|
||||
mock.patch.object(handler._pin_store, 'amend', return_value=self.amended_pending).start()
|
||||
# cancel mock — cancel 위임 검증용
|
||||
self.cancel_card = _active_card()
|
||||
mock.patch.object(handler._pin_store, 'cancel', return_value=self.cancel_card).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
|
||||
def test_amend_qty_only_succeeds(self):
|
||||
res = handler.amend_trade(qty=20)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
new_payload = handler._pin_store.amend.call_args[0][0]
|
||||
self.assertEqual(new_payload['qty'], 20)
|
||||
self.assertEqual(new_payload['order_type'], 'MARKET') # 기존 유지
|
||||
self.assertEqual(new_payload['symbol'], '005930') # 기존 유지
|
||||
|
||||
def test_amend_price_only_changes_to_limit_keeps_existing_type(self):
|
||||
# 기존 MARKET 유지, price 만 변경 — order_type 안 줬으면 그대로 MARKET
|
||||
res = handler.amend_trade(price=75500)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
new_payload = handler._pin_store.amend.call_args[0][0]
|
||||
# MARKET 일 땐 final_price 가 None (estimate 만 사용)
|
||||
self.assertEqual(new_payload['order_type'], 'MARKET')
|
||||
|
||||
def test_amend_order_type_to_limit_with_price(self):
|
||||
res = handler.amend_trade(order_type='LIMIT', price=75500)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
new_payload = handler._pin_store.amend.call_args[0][0]
|
||||
self.assertEqual(new_payload['order_type'], 'LIMIT')
|
||||
self.assertEqual(new_payload['price'], 75500)
|
||||
|
||||
def test_amend_budget_changes_qty_and_forces_market(self):
|
||||
res = handler.amend_trade(budget=2_000_000)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
new_payload = handler._pin_store.amend.call_args[0][0]
|
||||
# 2,000,000 / 75,100 = 26.6 → floor 26 + bump → 27주 (75,100 ≤ 30만원)
|
||||
self.assertEqual(new_payload['qty'], 27)
|
||||
self.assertEqual(new_payload['order_type'], 'MARKET')
|
||||
|
||||
def test_amend_zero_qty_delegates_to_cancel(self):
|
||||
res = handler.amend_trade(qty=0)
|
||||
self.assertTrue(res['ok'])
|
||||
# cancel 호출됨, amend 호출 안 됨
|
||||
handler._pin_store.cancel.assert_called_once()
|
||||
handler._pin_store.amend.assert_not_called()
|
||||
|
||||
def test_amend_zero_price_delegates_to_cancel(self):
|
||||
res = handler.amend_trade(price=0)
|
||||
self.assertTrue(res['ok'])
|
||||
handler._pin_store.cancel.assert_called_once()
|
||||
handler._pin_store.amend.assert_not_called()
|
||||
|
||||
def test_amend_zero_budget_delegates_to_cancel(self):
|
||||
res = handler.amend_trade(budget=0)
|
||||
self.assertTrue(res['ok'])
|
||||
handler._pin_store.cancel.assert_called_once()
|
||||
|
||||
def test_amend_zero_with_nonzero_rejected(self):
|
||||
# qty=0 + price=75000 동시 → AMBIGUOUS_AMEND
|
||||
res = handler.amend_trade(qty=0, price=75000)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('AMBIGUOUS_AMEND', res['message'])
|
||||
handler._pin_store.cancel.assert_not_called()
|
||||
|
||||
def test_amend_zero_with_order_type_rejected(self):
|
||||
res = handler.amend_trade(qty=0, order_type='LIMIT')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('AMBIGUOUS_AMEND', res['message'])
|
||||
|
||||
def test_amend_no_active_card_rejected(self):
|
||||
handler._pin_store.peek.return_value = None
|
||||
res = handler.amend_trade(qty=20)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('NO_ACTIVE_CARD', res['message'])
|
||||
|
||||
def test_amend_expired_card_rejected(self):
|
||||
handler._pin_store.peek.return_value = _active_card(expired=True)
|
||||
res = handler.amend_trade(qty=20)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('NO_ACTIVE_CARD', res['message'])
|
||||
|
||||
def test_amend_consumed_card_rejected(self):
|
||||
handler._pin_store.peek.return_value = _active_card(consumed=True)
|
||||
res = handler.amend_trade(qty=20)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('NO_ACTIVE_CARD', res['message'])
|
||||
|
||||
def test_amend_qty_and_budget_both_rejected(self):
|
||||
res = handler.amend_trade(qty=20, budget=1_000_000)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('AMBIGUOUS_INPUT', res['message'])
|
||||
|
||||
def test_amend_blocked_by_balance_guard(self):
|
||||
# 잔고를 50만원으로 낮추고 100주로 늘리면 guards.validate_request 의 INSUFFICIENT_BALANCE 에서 거부
|
||||
md = _md()
|
||||
md['balance_d2'] = 500_000
|
||||
self.collect.return_value = md
|
||||
res = handler.amend_trade(qty=100)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('INSUFFICIENT_BALANCE', res['message'])
|
||||
handler._pin_store.amend.assert_not_called()
|
||||
|
||||
def test_amend_invalid_order_type_rejected(self):
|
||||
res = handler.amend_trade(order_type='WEIRD')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('INVALID_ORDER_TYPE', res['message'])
|
||||
|
||||
def test_amend_card_carries_amended_marker(self):
|
||||
res = handler.amend_trade(qty=20)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertIn('수정됨', res['card_message'])
|
||||
|
||||
def test_amend_returns_new_pin(self):
|
||||
res = handler.amend_trade(qty=20)
|
||||
self.assertTrue(res['ok'])
|
||||
# PIN 재발급 — amend mock 이 5678 반환
|
||||
self.assertEqual(res['pin_message'], '5678')
|
||||
|
||||
def test_amend_account_only(self):
|
||||
res = handler.amend_trade(account='ISA')
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
new_payload, kwargs = (handler._pin_store.amend.call_args[0],
|
||||
handler._pin_store.amend.call_args[1])
|
||||
self.assertEqual(new_payload[0]['account'], 'ISA')
|
||||
self.assertEqual(new_payload[0]['symbol'], '005930') # 종목 유지
|
||||
self.assertEqual(kwargs.get('account_label'), 'ISA') # PinStore.amend 에 새 account 전달
|
||||
|
||||
def test_amend_account_to_spouse_passes_new_account_label(self):
|
||||
res = handler.amend_trade(account='가희_ISA')
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
kwargs = handler._pin_store.amend.call_args[1]
|
||||
self.assertEqual(kwargs.get('account_label'), '가희_ISA')
|
||||
|
||||
def test_amend_invalid_account_rejected(self):
|
||||
res = handler.amend_trade(account='UNKNOWN')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('INVALID_ACCOUNT', res['message'])
|
||||
handler._pin_store.amend.assert_not_called()
|
||||
|
||||
def test_amend_symbol_with_name(self):
|
||||
res = handler.amend_trade(symbol='035720', symbol_name='카카오')
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
new_payload = handler._pin_store.amend.call_args[0][0]
|
||||
self.assertEqual(new_payload['symbol'], '035720')
|
||||
self.assertEqual(new_payload['symbol_name'], '카카오')
|
||||
self.assertEqual(new_payload['account'], '일반') # 계좌 유지
|
||||
|
||||
def test_amend_symbol_without_name_rejected(self):
|
||||
res = handler.amend_trade(symbol='035720')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('MISSING_SYMBOL_NAME', res['message'])
|
||||
handler._pin_store.amend.assert_not_called()
|
||||
|
||||
def test_amend_invalid_symbol_format_rejected(self):
|
||||
# 5자리 — 6자리 강제
|
||||
res = handler.amend_trade(symbol='12345', symbol_name='임시')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('INVALID_SYMBOL', res['message'])
|
||||
|
||||
def test_amend_invalid_symbol_non_digit_rejected(self):
|
||||
res = handler.amend_trade(symbol='ABC123', symbol_name='임시')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('INVALID_SYMBOL', res['message'])
|
||||
|
||||
def test_amend_account_and_symbol_together(self):
|
||||
res = handler.amend_trade(account='ISA', symbol='035720', symbol_name='카카오', qty=10)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
new_payload = handler._pin_store.amend.call_args[0][0]
|
||||
self.assertEqual(new_payload['account'], 'ISA')
|
||||
self.assertEqual(new_payload['symbol'], '035720')
|
||||
self.assertEqual(new_payload['symbol_name'], '카카오')
|
||||
self.assertEqual(new_payload['qty'], 10)
|
||||
|
||||
def test_amend_zero_with_account_change_rejected(self):
|
||||
# 0 + account 변경 동시 → AMBIGUOUS_AMEND
|
||||
res = handler.amend_trade(qty=0, account='ISA')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('AMBIGUOUS_AMEND', res['message'])
|
||||
|
||||
def test_amend_zero_with_symbol_change_rejected(self):
|
||||
res = handler.amend_trade(qty=0, symbol='035720', symbol_name='카카오')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('AMBIGUOUS_AMEND', res['message'])
|
||||
|
||||
def test_amend_symbol_unchanged_no_name_required(self):
|
||||
# 같은 종목코드 다시 입력은 변경 아님 — symbol_name 미입력 OK
|
||||
res = handler.amend_trade(symbol='005930')
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
|
||||
def test_amend_orphan_symbol_name_rejected(self):
|
||||
# symbol_name 만 단독 변경 → 카드 이름 vs 발주 코드 불일치 위험 → 거부
|
||||
res = handler.amend_trade(symbol_name='카카오')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('ORPHAN_SYMBOL_NAME', res['message'])
|
||||
handler._pin_store.amend.assert_not_called()
|
||||
|
||||
def test_amend_same_symbol_name_no_change_passes(self):
|
||||
# 같은 이름 다시 입력은 변경 아님 → OK
|
||||
res = handler.amend_trade(symbol_name='삼성전자')
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
|
||||
def test_amend_market_to_limit_without_price_rejected(self):
|
||||
# 활성 카드가 MARKET 인 상태에서 LIMIT 으로만 전환 → price 없으면 거부
|
||||
# (기본 active fixture 가 order_type=MARKET, price=None)
|
||||
res = handler.amend_trade(order_type='LIMIT')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('MISSING_LIMIT_PRICE', res['message'])
|
||||
handler._pin_store.amend.assert_not_called()
|
||||
|
||||
def test_amend_market_to_limit_with_price_passes(self):
|
||||
res = handler.amend_trade(order_type='LIMIT', price=75500)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
new_payload = handler._pin_store.amend.call_args[0][0]
|
||||
self.assertEqual(new_payload['order_type'], 'LIMIT')
|
||||
self.assertEqual(new_payload['price'], 75500)
|
||||
|
||||
|
||||
class SubmitPinZeroCancelTests(unittest.TestCase):
|
||||
"""PIN echo 자리에 '0' 입력 → cancel 위임 검증."""
|
||||
|
||||
def setUp(self):
|
||||
mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None).start()
|
||||
mock.patch.object(handler, '_sweep_expired_and_notify', return_value=None).start()
|
||||
mock.patch.object(handler.ledger, 'append', return_value=None).start()
|
||||
cancel_card = _active_card()
|
||||
mock.patch.object(handler._pin_store, 'cancel', return_value=cancel_card).start()
|
||||
self.verify = mock.patch.object(handler._pin_store, 'verify').start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
|
||||
def test_pin_zero_delegates_to_cancel(self):
|
||||
res = handler.submit_with_pin('0', dry_run=False)
|
||||
self.assertTrue(res['ok'])
|
||||
# verify 는 호출 안 됨 — 0 분기에서 즉시 cancel
|
||||
self.verify.assert_not_called()
|
||||
handler._pin_store.cancel.assert_called_once()
|
||||
self.assertIn('취소', res['message'])
|
||||
|
||||
def test_pin_zero_with_whitespace_delegates_to_cancel(self):
|
||||
# 양옆 공백 strip
|
||||
res = handler.submit_with_pin(' 0 ', dry_run=False)
|
||||
self.assertTrue(res['ok'])
|
||||
self.verify.assert_not_called()
|
||||
|
||||
def test_pin_zero_with_no_active_card_returns_no_card(self):
|
||||
handler._pin_store.cancel.return_value = None
|
||||
res = handler.submit_with_pin('0', dry_run=False)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('활성 카드 없음', res['message'])
|
||||
|
||||
def test_pin_nonzero_proceeds_normal_flow(self):
|
||||
# "1234" 같은 정상 PIN 은 verify 경로 그대로
|
||||
self.verify.return_value = (False, '활성 카드 없음', None)
|
||||
res = handler.submit_with_pin('1234', dry_run=False)
|
||||
self.assertFalse(res['ok'])
|
||||
self.verify.assert_called_once()
|
||||
# cancel 은 호출 안 됨
|
||||
handler._pin_store.cancel.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""handler.propose_trade 의 budget 입력 통합 회귀 테스트.
|
||||
|
||||
datasource·sidecar·pin_store·ledger 를 mock 해서 budget 환산 후 12가드 체인이 그대로 흘러가는지 검증.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from orders import handler, ledger
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def _md(now=None, orderbook=None):
|
||||
return {
|
||||
'now': now or datetime(2026, 5, 6, 10, 0, tzinfo=KST),
|
||||
'is_holiday': False,
|
||||
'nxt_eligible': True,
|
||||
'current_price': 75050,
|
||||
'prev_close': 75000,
|
||||
'upper_limit': 100000,
|
||||
'lower_limit': 50000,
|
||||
'halt': False,
|
||||
'vi': False,
|
||||
'orderbook': orderbook if orderbook is not None else {
|
||||
'asks': [{'price': 75100, 'qty': 200}, {'price': 75200, 'qty': 150}],
|
||||
'bids': [{'price': 75000, 'qty': 180}, {'price': 74900, 'qty': 220}],
|
||||
},
|
||||
'broker_executions': [],
|
||||
'broker_query_error': None,
|
||||
'balance_d2': 5_000_000,
|
||||
}
|
||||
|
||||
|
||||
class HandlerBudgetTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# 모든 외부 의존 mock — budget 환산 → guards → pin_store 흐름만 검증
|
||||
self.collect = mock.patch.object(handler.datasource, 'collect_market_data',
|
||||
return_value=_md()).start()
|
||||
mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None).start()
|
||||
mock.patch.object(handler, '_sweep_expired_and_notify', return_value=None).start()
|
||||
mock.patch.object(handler.ledger, 'append', return_value=None).start()
|
||||
mock.patch.object(ledger, 'last_terminal_event', return_value=None).start()
|
||||
mock.patch.object(ledger, 'last_event_for_symbol', return_value=None).start()
|
||||
# pin_store.issue 가 카드 발행 — 단순한 fake 객체 반환
|
||||
fake_pending = mock.MagicMock()
|
||||
fake_pending.card_id = 'TEST'
|
||||
fake_pending.pin = '1234'
|
||||
fake_pending.expiry_seconds = 120
|
||||
fake_pending.account_label = '일반'
|
||||
mock.patch.object(handler._pin_store, 'issue', return_value=fake_pending).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
|
||||
def test_budget_market_buy_succeeds_with_correct_qty(self):
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=None, order_type='MARKET', budget=1_000_000,
|
||||
)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
# ref_price=75,100원 ≤ 30만원 → BUY +1 적용. 13 → 14주.
|
||||
call_args = handler._pin_store.issue.call_args
|
||||
self.assertIsNotNone(call_args)
|
||||
payload = call_args[0][1]
|
||||
self.assertEqual(payload['qty'], 14)
|
||||
self.assertEqual(payload['order_type'], 'MARKET')
|
||||
|
||||
def test_budget_too_small_rejects_before_card(self):
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=None, order_type='MARKET', budget=50_000,
|
||||
)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('BUDGET_TOO_SMALL', res['message'])
|
||||
# 환산 거부 시 카드 발행 없어야 함
|
||||
handler._pin_store.issue.assert_not_called()
|
||||
|
||||
def test_budget_invalid_zero_rejects(self):
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=None, order_type='MARKET', budget=0,
|
||||
)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('BUDGET_INVALID', res['message'])
|
||||
|
||||
def test_qty_and_budget_both_set_rejected(self):
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=5, order_type='MARKET', budget=1_000_000,
|
||||
)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('AMBIGUOUS_INPUT', res['message'])
|
||||
|
||||
def test_neither_qty_nor_budget_rejected(self):
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=None, order_type='MARKET', budget=None,
|
||||
)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('MISSING_QTY', res['message'])
|
||||
|
||||
def test_budget_sell_uses_bid1(self):
|
||||
sell_md = _md()
|
||||
sell_md.pop('balance_d2', None)
|
||||
sell_md['position_qty'] = 100
|
||||
self.collect.return_value = sell_md
|
||||
res = handler.propose_trade(
|
||||
account='ISA', side='SELL', symbol='005930', symbol_name='삼성전자',
|
||||
qty=None, order_type='MARKET', budget=500_000,
|
||||
)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
payload = handler._pin_store.issue.call_args[0][1]
|
||||
self.assertEqual(payload['qty'], 6) # 500_000 // 75_000
|
||||
self.assertEqual(payload['side'], 'SELL')
|
||||
|
||||
def test_budget_passes_balance_guard_when_sufficient(self):
|
||||
# 잔고 5,000,000원 / 환산 13주 × 75,100 ≒ 976,300원 → 통과해야 함
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=None, order_type='MARKET', budget=1_000_000,
|
||||
)
|
||||
self.assertTrue(res['ok'])
|
||||
|
||||
def test_budget_buy_fallback_uses_current_price_when_orderbook_missing(self):
|
||||
"""호가창 비어도 current_price 로 환산해서 카드 발행 통과."""
|
||||
md = _md()
|
||||
md['orderbook'] = None
|
||||
md['current_price'] = 75050
|
||||
self.collect.return_value = md
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=None, order_type='LIMIT', price=75000, budget=1_000_000,
|
||||
)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
payload = handler._pin_store.issue.call_args[0][1]
|
||||
# ref_price=75,050(fallback) ≤ 30만원 → BUY +1 적용 → 14주
|
||||
self.assertEqual(payload['qty'], 14)
|
||||
|
||||
def test_aggressive_limit_buy_fallback_when_orderbook_missing(self):
|
||||
"""AGGRESSIVE_LIMIT BUY — 호가창 없어도 current_price+1tick 으로 카드 발행."""
|
||||
md = _md()
|
||||
md['orderbook'] = None
|
||||
md['current_price'] = 75050
|
||||
self.collect.return_value = md
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=10, order_type='AGGRESSIVE_LIMIT', budget=None,
|
||||
)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
payload = handler._pin_store.issue.call_args[0][1]
|
||||
self.assertEqual(payload['order_type'], 'AGGRESSIVE_LIMIT')
|
||||
self.assertEqual(payload['price'], 75150) # 75050 + 100tick
|
||||
|
||||
def test_aggressive_limit_buy_no_orderbook_no_quote_rejects(self):
|
||||
md = _md()
|
||||
md['orderbook'] = None
|
||||
md['current_price'] = 0
|
||||
self.collect.return_value = md
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=10, order_type='AGGRESSIVE_LIMIT', budget=None,
|
||||
)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('NO_ORDERBOOK', res['message'])
|
||||
|
||||
def test_budget_blocked_by_balance_guard_when_insufficient(self):
|
||||
# 잔고를 50만원으로 낮추고 100만원 예산 시도 → 환산 후 잔고 가드에서 거부
|
||||
self.collect.return_value = _md()
|
||||
self.collect.return_value['balance_d2'] = 500_000
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=None, order_type='MARKET', budget=1_000_000,
|
||||
)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('INSUFFICIENT_BALANCE', res['message'])
|
||||
|
||||
def test_budget_buy_no_bump_when_ref_price_above_threshold(self):
|
||||
# ref_price 가 30만원 초과면 +1 정책 미적용 → floor 유지
|
||||
md = _md()
|
||||
md['orderbook'] = {
|
||||
'asks': [{'price': 350_000, 'qty': 50}, {'price': 350_500, 'qty': 30}],
|
||||
'bids': [{'price': 349_500, 'qty': 40}, {'price': 349_000, 'qty': 60}],
|
||||
}
|
||||
md['balance_d2'] = 50_000_000
|
||||
self.collect.return_value = md
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=None, order_type='MARKET', budget=1_000_000,
|
||||
)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
payload = handler._pin_store.issue.call_args[0][1]
|
||||
# 1,000,000 // 350,000 = 2 (remainder 300,000) — but ref_price > 300,000 → bump 안 함
|
||||
self.assertEqual(payload['qty'], 2)
|
||||
|
||||
def test_budget_buy_no_bump_when_remainder_zero(self):
|
||||
# 예산이 ref_price 의 정확한 배수면 remainder=0 → +1 안 함
|
||||
md = _md()
|
||||
md['orderbook'] = {
|
||||
'asks': [{'price': 100_000, 'qty': 50}, {'price': 100_100, 'qty': 30}],
|
||||
'bids': [{'price': 99_900, 'qty': 40}, {'price': 99_800, 'qty': 60}],
|
||||
}
|
||||
md['balance_d2'] = 50_000_000
|
||||
self.collect.return_value = md
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
|
||||
qty=None, order_type='MARKET', budget=1_000_000,
|
||||
)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
payload = handler._pin_store.issue.call_args[0][1]
|
||||
# 1,000,000 // 100,000 = 10 정확히 — bump 안 함
|
||||
self.assertEqual(payload['qty'], 10)
|
||||
|
||||
def test_budget_sell_never_bumps_even_under_threshold(self):
|
||||
# SELL 은 ref_price 가 30만원 이하여도 +1 안 함 (보유수량 초과 매도 방지)
|
||||
sell_md = _md()
|
||||
sell_md.pop('balance_d2', None)
|
||||
sell_md['position_qty'] = 100
|
||||
self.collect.return_value = sell_md
|
||||
res = handler.propose_trade(
|
||||
account='ISA', side='SELL', symbol='005930', symbol_name='삼성전자',
|
||||
qty=None, order_type='MARKET', budget=500_000,
|
||||
)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
payload = handler._pin_store.issue.call_args[0][1]
|
||||
# 500,000 // 75,000 = 6 (remainder 50,000) — SELL 이라 bump 안 함
|
||||
self.assertEqual(payload['qty'], 6)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""kiwoom_order.submit 응답 처리 회귀 테스트.
|
||||
|
||||
명세 (PDF 2026-05-07 검증):
|
||||
- kt10000/kt10001 응답 필드: ord_no, dmst_stex_tp, return_code, return_msg
|
||||
- 정상: return_code == 0
|
||||
- ord_seq_no, rt_cd 같은 키는 명세에 없음
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from orders import kiwoom_order, ledger, sidecar
|
||||
|
||||
|
||||
class SubmitResponseTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mock.patch.object(sidecar, 'guard_or_raise', return_value=None).start()
|
||||
mock.patch.object(ledger, 'append', return_value=None).start()
|
||||
mock.patch.object(ledger, 'find_recent_idempotency', return_value=False).start()
|
||||
# idempotency_hash 는 순수 함수라 mock 불필요
|
||||
self.kc_post = mock.patch('orders.kiwoom_order.kc._http_post_json').start()
|
||||
mock.patch('orders.kiwoom_order.kc.auth_headers', return_value={}).start()
|
||||
mock.patch('orders.kiwoom_order.kc.base_url', return_value='https://api.kiwoom.com').start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
|
||||
def _submit(self):
|
||||
return kiwoom_order.submit(
|
||||
account_label='일반', side='BUY', symbol='005930', qty=1,
|
||||
price=None, order_type='MARKET', routing_suffix='_AL',
|
||||
dry_run=False, card_id='TEST',
|
||||
)
|
||||
|
||||
def test_normal_response_succeeds(self):
|
||||
"""명세 정상 응답 — return_code=0, ord_no 추출."""
|
||||
self.kc_post.return_value = {
|
||||
'ord_no': '0000138',
|
||||
'dmst_stex_tp': 'KRX',
|
||||
'return_code': 0,
|
||||
'return_msg': '매수주문이 완료되었습니다.',
|
||||
}
|
||||
res = self._submit()
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(res['ord_no'], '0000138')
|
||||
|
||||
def test_broker_reject_when_return_code_nonzero(self):
|
||||
"""return_code != 0 → BROKER_REJECT."""
|
||||
self.kc_post.return_value = {
|
||||
'return_code': 1,
|
||||
'return_msg': '주문불가종목',
|
||||
}
|
||||
res = self._submit()
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertEqual(res['reason'], 'BROKER_REJECT')
|
||||
|
||||
def test_unexpected_response_type_falls_to_network(self):
|
||||
"""응답이 dict 아닌 경우 — try 안의 .get 호출 AttributeError → NETWORK."""
|
||||
self.kc_post.return_value = 'unexpected string'
|
||||
res = self._submit()
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertEqual(res['reason'], 'NETWORK')
|
||||
|
||||
def test_ord_seq_no_not_used(self):
|
||||
"""명세에 없는 ord_seq_no fallback은 더 이상 동작하지 않음.
|
||||
|
||||
ord_no 없고 ord_seq_no만 있는 (가짜) 응답 → ord_no 빈 문자열.
|
||||
"""
|
||||
self.kc_post.return_value = {
|
||||
'ord_seq_no': '0000999', # 명세에 없는 키
|
||||
'return_code': 0,
|
||||
}
|
||||
res = self._submit()
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(res['ord_no'], '') # ord_seq_no fallback 제거됨
|
||||
|
||||
def test_rt_cd_not_used(self):
|
||||
"""명세에 없는 rt_cd 키는 무시. return_code 누락 시 None != 0 → BROKER_REJECT."""
|
||||
self.kc_post.return_value = {
|
||||
'ord_no': '0000138',
|
||||
'rt_cd': '0', # 명세에 없는 키 — 이걸로는 정상 판정 안 됨
|
||||
}
|
||||
res = self._submit()
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertEqual(res['reason'], 'BROKER_REJECT')
|
||||
|
||||
def test_8005_token_retry_then_success(self):
|
||||
"""첫 호출 8005 토큰 만료 → 재발급 → 두 번째 호출 정상."""
|
||||
self.kc_post.side_effect = [
|
||||
{'return_code': 1, 'return_msg': '8005 Token이 유효하지 않습니다'},
|
||||
{'ord_no': '0000200', 'return_code': 0, 'return_msg': '정상'},
|
||||
]
|
||||
with mock.patch('orders.kiwoom_order.kc.issue_token', return_value=None):
|
||||
res = self._submit()
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(res['ord_no'], '0000200')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""datasource._nxt_eligible 캐시 lookup 회귀 테스트.
|
||||
|
||||
ka10099 (`stock_codes.json`) 캐시의 `nxt_enable` 필드를 사용해 NXT 거래가능 여부 판단.
|
||||
캐시 미스/구 스키마는 보수적 True 유지 (현재 동작 보존).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from orders import datasource
|
||||
|
||||
|
||||
class NxtEligibleTests(unittest.TestCase):
|
||||
def test_returns_true_when_meta_says_yes(self):
|
||||
with mock.patch.object(datasource.kc, 'lookup_stock_meta',
|
||||
return_value={'code': '005930', 'nxt_enable': True}):
|
||||
self.assertTrue(datasource._nxt_eligible('005930', {}))
|
||||
|
||||
def test_returns_false_when_meta_says_no(self):
|
||||
"""NXT 미상장 종목 — 가드가 NXT 시간대 매수 거부."""
|
||||
with mock.patch.object(datasource.kc, 'lookup_stock_meta',
|
||||
return_value={'code': '900100', 'nxt_enable': False}):
|
||||
self.assertFalse(datasource._nxt_eligible('900100', {}))
|
||||
|
||||
def test_cache_miss_falls_to_conservative_true(self):
|
||||
"""캐시에 없는 종목 → True (사후 broker reject로 안전판)."""
|
||||
with mock.patch.object(datasource.kc, 'lookup_stock_meta', return_value=None):
|
||||
self.assertTrue(datasource._nxt_eligible('999999', {}))
|
||||
|
||||
def test_old_schema_cache_falls_to_conservative_true(self):
|
||||
"""구 스키마 캐시 (nxt_enable 필드 없음) → True 유지."""
|
||||
with mock.patch.object(datasource.kc, 'lookup_stock_meta',
|
||||
return_value={'code': '005930', 'name': '삼성전자', 'market': 'KOSPI'}):
|
||||
self.assertTrue(datasource._nxt_eligible('005930', {}))
|
||||
|
||||
def test_lookup_exception_falls_to_conservative_true(self):
|
||||
"""lookup 자체가 예외 → True 유지 (가드 안전판은 사후 broker reject)."""
|
||||
with mock.patch.object(datasource.kc, 'lookup_stock_meta',
|
||||
side_effect=RuntimeError('cache read fail')):
|
||||
self.assertTrue(datasource._nxt_eligible('005930', {}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""kiwoom_client._call_paginated 회귀 테스트.
|
||||
|
||||
명세상 list 반환 TR 응답 헤더에 cont-yn/next-key 정의됨. 1페이지로 끝나는 경우(cont-yn=N)와
|
||||
다중 페이지(cont-yn=Y → next-key 후속 호출) 모두 list 누적이 정확한지 검증.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
_PARENT = Path(__file__).resolve().parent.parent.parent
|
||||
if str(_PARENT) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT))
|
||||
|
||||
import kiwoom_client as kc
|
||||
|
||||
|
||||
class CallPaginatedTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mock.patch.object(kc, 'auth_headers', return_value={}).start()
|
||||
mock.patch.object(kc, 'base_url', return_value='https://api.kiwoom.com').start()
|
||||
mock.patch.object(kc, 'issue_token', return_value=None).start()
|
||||
mock.patch('kiwoom_client.time.sleep', return_value=None).start()
|
||||
self.post = mock.patch.object(kc, '_http_post_full').start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
|
||||
def test_single_page_returns_immediately(self):
|
||||
"""cont-yn=N → 1회 호출, list 그대로 반환."""
|
||||
self.post.return_value = (
|
||||
{'return_code': 0, 'tot_pl_amt': '1000', 'tdy_trde_diary': [{'stk_cd': 'A005930'}]},
|
||||
{'cont-yn': 'N', 'next-key': ''},
|
||||
)
|
||||
out = kc._call_paginated('일반', 'ka10170', {}, list_field='tdy_trde_diary')
|
||||
self.assertEqual(self.post.call_count, 1)
|
||||
self.assertEqual(len(out['tdy_trde_diary']), 1)
|
||||
self.assertEqual(out['tot_pl_amt'], '1000')
|
||||
|
||||
def test_multi_page_accumulates_list(self):
|
||||
"""cont-yn=Y → next-key로 후속 호출, list 누적."""
|
||||
self.post.side_effect = [
|
||||
(
|
||||
{'return_code': 0, 'tot_pl_amt': '500',
|
||||
'tdy_trde_diary': [{'stk_cd': 'A005930'}, {'stk_cd': 'A035720'}]},
|
||||
{'cont-yn': 'Y', 'next-key': 'page2'},
|
||||
),
|
||||
(
|
||||
{'return_code': 0, 'tot_pl_amt': '500',
|
||||
'tdy_trde_diary': [{'stk_cd': 'A000660'}]},
|
||||
{'cont-yn': 'N', 'next-key': ''},
|
||||
),
|
||||
]
|
||||
out = kc._call_paginated('일반', 'ka10170', {}, list_field='tdy_trde_diary')
|
||||
self.assertEqual(self.post.call_count, 2)
|
||||
self.assertEqual(len(out['tdy_trde_diary']), 3)
|
||||
codes = [it['stk_cd'] for it in out['tdy_trde_diary']]
|
||||
self.assertEqual(codes, ['A005930', 'A035720', 'A000660'])
|
||||
|
||||
def test_three_pages(self):
|
||||
self.post.side_effect = [
|
||||
({'return_code': 0, 'list': [1, 2]}, {'cont-yn': 'Y', 'next-key': 'p2'}),
|
||||
({'return_code': 0, 'list': [3]}, {'cont-yn': 'Y', 'next-key': 'p3'}),
|
||||
({'return_code': 0, 'list': [4, 5]}, {'cont-yn': 'N', 'next-key': ''}),
|
||||
]
|
||||
out = kc._call_paginated('일반', 'tr', {}, list_field='list')
|
||||
self.assertEqual(self.post.call_count, 3)
|
||||
self.assertEqual(out['list'], [1, 2, 3, 4, 5])
|
||||
|
||||
def test_max_pages_safety_cap(self):
|
||||
"""무한 cont-yn=Y 응답 → max_pages 초과 시 RuntimeError."""
|
||||
self.post.return_value = (
|
||||
{'return_code': 0, 'list': [1]},
|
||||
{'cont-yn': 'Y', 'next-key': 'next'},
|
||||
)
|
||||
with self.assertRaisesRegex(RuntimeError, '페이지 한도'):
|
||||
kc._call_paginated('일반', 'tr', {}, list_field='list', max_pages=3)
|
||||
|
||||
def test_cont_yn_y_without_next_key_stops(self):
|
||||
"""cont-yn=Y지만 next-key 빈 값 → 페이징 중단."""
|
||||
self.post.return_value = (
|
||||
{'return_code': 0, 'list': [1]},
|
||||
{'cont-yn': 'Y', 'next-key': ''},
|
||||
)
|
||||
out = kc._call_paginated('일반', 'tr', {}, list_field='list')
|
||||
self.assertEqual(self.post.call_count, 1)
|
||||
self.assertEqual(out['list'], [1])
|
||||
|
||||
def test_first_page_8005_token_retry(self):
|
||||
"""첫 페이지 8005 → 토큰 재발급 후 재호출 정상."""
|
||||
self.post.side_effect = [
|
||||
({'return_code': 1, 'return_msg': '8005 Token이 유효하지 않습니다'}, {}),
|
||||
({'return_code': 0, 'list': [1, 2]}, {'cont-yn': 'N', 'next-key': ''}),
|
||||
]
|
||||
out = kc._call_paginated('일반', 'tr', {}, list_field='list')
|
||||
self.assertEqual(self.post.call_count, 2)
|
||||
self.assertEqual(out['list'], [1, 2])
|
||||
|
||||
def test_non_8005_error_raises(self):
|
||||
self.post.return_value = (
|
||||
{'return_code': 1, 'return_msg': '잘못된 파라미터'},
|
||||
{},
|
||||
)
|
||||
with self.assertRaisesRegex(RuntimeError, '잘못된 파라미터'):
|
||||
kc._call_paginated('일반', 'tr', {}, list_field='list')
|
||||
|
||||
def test_empty_list_field_initialized(self):
|
||||
"""1페이지 응답에 list_field 없어도 빈 list로 초기화."""
|
||||
self.post.return_value = (
|
||||
{'return_code': 0},
|
||||
{'cont-yn': 'N', 'next-key': ''},
|
||||
)
|
||||
out = kc._call_paginated('일반', 'tr', {}, list_field='list')
|
||||
self.assertEqual(out['list'], [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""guards.evaluate_stock_state 회귀 테스트.
|
||||
|
||||
정책 (등급별 차등 — 2026-05-07 결정):
|
||||
- 거부: orderWarning ∈ {2 정리매매, 4 투자위험} 또는 state 에 '거래정지'·'정리매매' 키워드
|
||||
- 경고: orderWarning ∈ {1 ETF주의, 3 단기과열, 5 투자경과} 또는 state 에 '관리종목'
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from orders import guards, handler, ledger
|
||||
|
||||
|
||||
class EvaluateStockStateTests(unittest.TestCase):
|
||||
def test_normal_stock_passes_no_warning(self):
|
||||
meta = {'code': '005930', 'state': '증거금20%|담보대출|신용가능', 'order_warning': '0'}
|
||||
out = guards.evaluate_stock_state(meta)
|
||||
self.assertTrue(out['result'].ok)
|
||||
self.assertIsNone(out['warning'])
|
||||
|
||||
def test_no_meta_passes_no_warning(self):
|
||||
out = guards.evaluate_stock_state(None)
|
||||
self.assertTrue(out['result'].ok)
|
||||
self.assertIsNone(out['warning'])
|
||||
|
||||
def test_order_warning_2_정리매매_rejects(self):
|
||||
meta = {'code': 'X', 'state': '', 'order_warning': '2'}
|
||||
out = guards.evaluate_stock_state(meta)
|
||||
self.assertFalse(out['result'].ok)
|
||||
self.assertEqual(out['result'].code, 'STOCK_STATE_BLOCKED')
|
||||
self.assertIn('정리매매', out['result'].message)
|
||||
|
||||
def test_order_warning_4_투자위험_rejects(self):
|
||||
meta = {'code': 'X', 'state': '', 'order_warning': '4'}
|
||||
out = guards.evaluate_stock_state(meta)
|
||||
self.assertFalse(out['result'].ok)
|
||||
self.assertIn('투자위험', out['result'].message)
|
||||
|
||||
def test_state_거래정지_rejects(self):
|
||||
meta = {'code': 'X', 'state': '거래정지', 'order_warning': '0'}
|
||||
out = guards.evaluate_stock_state(meta)
|
||||
self.assertFalse(out['result'].ok)
|
||||
self.assertIn('거래정지', out['result'].message)
|
||||
|
||||
def test_state_정리매매_rejects(self):
|
||||
meta = {'code': 'X', 'state': '정리매매|거래제한', 'order_warning': '0'}
|
||||
out = guards.evaluate_stock_state(meta)
|
||||
self.assertFalse(out['result'].ok)
|
||||
|
||||
def test_order_warning_1_ETF주의_warns(self):
|
||||
meta = {'code': 'X', 'state': '', 'order_warning': '1'}
|
||||
out = guards.evaluate_stock_state(meta)
|
||||
self.assertTrue(out['result'].ok)
|
||||
self.assertIsNotNone(out['warning'])
|
||||
self.assertIn('ETF투자주의요망', out['warning'])
|
||||
|
||||
def test_order_warning_3_단기과열_warns(self):
|
||||
meta = {'code': 'X', 'state': '', 'order_warning': '3'}
|
||||
out = guards.evaluate_stock_state(meta)
|
||||
self.assertTrue(out['result'].ok)
|
||||
self.assertIn('단기과열', out['warning'])
|
||||
|
||||
def test_order_warning_5_투자경과_warns(self):
|
||||
meta = {'code': 'X', 'state': '', 'order_warning': '5'}
|
||||
out = guards.evaluate_stock_state(meta)
|
||||
self.assertTrue(out['result'].ok)
|
||||
self.assertIn('투자경과', out['warning'])
|
||||
|
||||
def test_state_관리종목_warns(self):
|
||||
meta = {'code': 'X', 'state': '관리종목|증거금100%', 'order_warning': '0'}
|
||||
out = guards.evaluate_stock_state(meta)
|
||||
self.assertTrue(out['result'].ok)
|
||||
self.assertIn('관리종목', out['warning'])
|
||||
|
||||
def test_reject_takes_precedence_over_warning(self):
|
||||
"""state에 '관리종목'(경고) + orderWarning=2(정리매매·거부) → 거부 우선."""
|
||||
meta = {'code': 'X', 'state': '관리종목', 'order_warning': '2'}
|
||||
out = guards.evaluate_stock_state(meta)
|
||||
self.assertFalse(out['result'].ok)
|
||||
|
||||
|
||||
def _md_with_meta(stock_meta):
|
||||
"""handler 통합 테스트용 fixture market_data."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
KST = timezone(timedelta(hours=9))
|
||||
return {
|
||||
'now': datetime(2026, 5, 6, 10, 0, tzinfo=KST),
|
||||
'is_holiday': False,
|
||||
'nxt_eligible': True,
|
||||
'current_price': 75050,
|
||||
'prev_close': 75000,
|
||||
'upper_limit': 100000,
|
||||
'lower_limit': 50000,
|
||||
'halt': False,
|
||||
'vi': False,
|
||||
'orderbook': {
|
||||
'asks': [{'price': 75100, 'qty': 200}],
|
||||
'bids': [{'price': 75000, 'qty': 180}],
|
||||
},
|
||||
'broker_executions': [],
|
||||
'broker_query_error': None,
|
||||
'balance_d2': 5_000_000,
|
||||
'stock_meta': stock_meta,
|
||||
}
|
||||
|
||||
|
||||
class HandlerIntegrationTests(unittest.TestCase):
|
||||
"""propose_trade가 종목 상태 가드를 적용하는지 통합 검증."""
|
||||
|
||||
def setUp(self):
|
||||
self.collect = mock.patch.object(handler.datasource, 'collect_market_data').start()
|
||||
mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None).start()
|
||||
mock.patch.object(handler, '_sweep_expired_and_notify', return_value=None).start()
|
||||
mock.patch.object(handler.ledger, 'append', return_value=None).start()
|
||||
mock.patch.object(ledger, 'last_terminal_event', return_value=None).start()
|
||||
mock.patch.object(ledger, 'last_event_for_symbol', return_value=None).start()
|
||||
fake_pending = mock.MagicMock()
|
||||
fake_pending.card_id = 'TEST'
|
||||
fake_pending.pin = '1234'
|
||||
fake_pending.expiry_seconds = 120
|
||||
fake_pending.account_label = '일반'
|
||||
self.issue = mock.patch.object(handler._pin_store, 'issue',
|
||||
return_value=fake_pending).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
|
||||
def test_정리매매_rejects_before_card(self):
|
||||
self.collect.return_value = _md_with_meta(
|
||||
{'code': '005930', 'state': '', 'order_warning': '2'}
|
||||
)
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='X',
|
||||
qty=1, order_type='LIMIT', price=75000,
|
||||
)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('STOCK_STATE_BLOCKED', res['message'])
|
||||
self.issue.assert_not_called()
|
||||
|
||||
def test_관리종목_card_includes_warning(self):
|
||||
self.collect.return_value = _md_with_meta(
|
||||
{'code': '005930', 'state': '관리종목', 'order_warning': '0'}
|
||||
)
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='X',
|
||||
qty=1, order_type='LIMIT', price=75000,
|
||||
)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
self.assertIn('관리종목', res['card_message'])
|
||||
|
||||
def test_normal_stock_no_warning_in_card(self):
|
||||
self.collect.return_value = _md_with_meta(
|
||||
{'code': '005930', 'state': '증거금20%', 'order_warning': '0'}
|
||||
)
|
||||
res = handler.propose_trade(
|
||||
account='일반', side='BUY', symbol='005930', symbol_name='X',
|
||||
qty=1, order_type='LIMIT', price=75000,
|
||||
)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertNotIn('키움 경고', res['card_message'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""매월 1일 04:30 — 본인 계좌 잔액을 골디 inbox에 envelope로 떨어뜨린다.
|
||||
|
||||
가희 계좌는 제외 (label prefix '가희_'). 본인 계좌별 평가액(kt00018)·예수금(kt00001)·총자산을 집계.
|
||||
LLM 불필요 — launchd로 실행. 실패 시 레이 텔레그램으로 자가 알림.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo('Asia/Seoul')
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
sys.path.insert(0, str(WORKSPACE / 'scripts'))
|
||||
|
||||
import kiwoom_client as kw # noqa: E402
|
||||
|
||||
INBOX_DIR = Path('/Users/snowoyh/.openclaw/agents/budget/inbox/incoming')
|
||||
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
|
||||
TELEGRAM_ACCOUNT = 'stock'
|
||||
TOPIC = 'securities_balance'
|
||||
SCHEMA_VERSION = 1
|
||||
GAHEE_PREFIX = '가희_'
|
||||
|
||||
|
||||
def send_telegram(text: str) -> bool:
|
||||
try:
|
||||
cfg = json.loads(CONFIG_PATH.read_text())
|
||||
acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
|
||||
token = acct['botToken']
|
||||
chat_ids = acct.get('allowFrom') or []
|
||||
except Exception as e:
|
||||
print(f'telegram cfg load failed: {e}', file=sys.stderr)
|
||||
return False
|
||||
if not chat_ids:
|
||||
return False
|
||||
url = f'https://api.telegram.org/bot{token}/sendMessage'
|
||||
ok = True
|
||||
for chat_id in chat_ids:
|
||||
data = urllib.parse.urlencode({
|
||||
'chat_id': chat_id,
|
||||
'text': text[:4000],
|
||||
'disable_web_page_preview': 'true',
|
||||
}).encode()
|
||||
try:
|
||||
req = urllib.request.Request(url, data=data, method='POST')
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
if r.status != 200:
|
||||
ok = False
|
||||
except Exception as e:
|
||||
print(f'telegram send failed: {e}', file=sys.stderr)
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
def collect_owner_accounts() -> list[dict]:
|
||||
accounts = kw.list_accounts()
|
||||
out = []
|
||||
for a in accounts:
|
||||
label = a['label']
|
||||
if label.startswith(GAHEE_PREFIX):
|
||||
continue
|
||||
balance = kw.get_balance(label)
|
||||
positions = kw.get_positions(label)
|
||||
eval_amount = sum(p.get('evlt_amt', 0) for p in positions)
|
||||
# d2_entra(D+2 정산예수금) — 미결제 매수/매도까지 반영한 실제 가용 예수금.
|
||||
# entr은 D+2 결제 전까지 변하지 않아 미결제 매도대금 누락 가능.
|
||||
deposit = balance.get('d2_entra', 0)
|
||||
out.append({
|
||||
'label': label,
|
||||
'account_no': a.get('account_no', ''),
|
||||
'deposit': deposit,
|
||||
'eval_amount': eval_amount,
|
||||
'total': deposit + eval_amount,
|
||||
'position_count': len(positions),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def build_message(accounts: list[dict]) -> dict:
|
||||
now = datetime.now(KST)
|
||||
return {
|
||||
'message_id': str(uuid.uuid4()),
|
||||
'from': 'stock',
|
||||
'to': 'budget',
|
||||
'topic': TOPIC,
|
||||
'created_at': now.isoformat(),
|
||||
'schema_version': SCHEMA_VERSION,
|
||||
'payload': {
|
||||
'as_of': now.strftime('%Y-%m-%d'),
|
||||
'owner_scope': 'self_only',
|
||||
'accounts': accounts,
|
||||
'totals': {
|
||||
'deposit': sum(a['deposit'] for a in accounts),
|
||||
'eval_amount': sum(a['eval_amount'] for a in accounts),
|
||||
'total': sum(a['total'] for a in accounts),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_message(msg: dict) -> Path:
|
||||
INBOX_DIR.mkdir(parents=True, exist_ok=True)
|
||||
iso_compact = msg['created_at'].replace(':', '').replace('-', '').split('+')[0]
|
||||
filename = f"stock__{TOPIC}__{iso_compact}.json"
|
||||
path = INBOX_DIR / filename
|
||||
path.write_text(json.dumps(msg, ensure_ascii=False, indent=2))
|
||||
return path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
accounts = collect_owner_accounts()
|
||||
if not accounts:
|
||||
raise RuntimeError('본인 계좌 0개 — 자격증명 또는 prefix 필터 점검 필요')
|
||||
msg = build_message(accounts)
|
||||
path = write_message(msg)
|
||||
total = msg['payload']['totals']['total']
|
||||
print(f'sent: {path.name} | accounts={len(accounts)} | total={total:,}원')
|
||||
return 0
|
||||
except Exception as e:
|
||||
err = f'⚠️ [send_balance_to_budget] 실패\n{type(e).__name__}: {e}\n\n{traceback.format_exc()[-500:]}'
|
||||
print(err, file=sys.stderr)
|
||||
send_telegram(err)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
+1327
File diff suppressed because it is too large
Load Diff
+296
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""종목별 매매 기록 — ka10170 당일매매일지를 일자별로 적재·조회.
|
||||
|
||||
키움 REST에는 기간 거래내역 API가 없어 매일 ka10170을 호출해 누적한다.
|
||||
- 저장: state/trade_journal.jsonl (한 줄 = (date, account, code) 레코드)
|
||||
- 재실행 시 같은 (date, account) 행을 제거 후 재적재 → idempotent
|
||||
- 휴장일/주말은 기본 skip (--force로 강제)
|
||||
|
||||
CLI:
|
||||
collect [--date YYYYMMDD] [--force] [--quiet]
|
||||
show <code|name>
|
||||
query [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--account L] [--code C]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import kiwoom_client as kw
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
JOURNAL = WORKSPACE / 'state' / 'trade_journal.jsonl'
|
||||
HOLIDAYS = WORKSPACE / 'state' / 'market_holidays.json'
|
||||
|
||||
|
||||
def is_market_open(d: datetime) -> bool:
|
||||
if d.weekday() >= 5:
|
||||
return False
|
||||
iso = d.strftime('%Y-%m-%d')
|
||||
try:
|
||||
data = json.loads(HOLIDAYS.read_text())
|
||||
return iso not in data.get('holidays', {})
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
|
||||
|
||||
def _owner(label: str) -> str:
|
||||
return '가희' if label.startswith('가희_') else '본인'
|
||||
|
||||
|
||||
def _load_all() -> list[dict]:
|
||||
if not JOURNAL.exists():
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for ln in JOURNAL.read_text().splitlines():
|
||||
ln = ln.strip()
|
||||
if not ln:
|
||||
continue
|
||||
try:
|
||||
out.append(json.loads(ln))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _save_all(rows: list[dict]) -> None:
|
||||
tmp = JOURNAL.with_suffix('.jsonl.tmp')
|
||||
if rows:
|
||||
tmp.write_text('\n'.join(json.dumps(r, ensure_ascii=False) for r in rows) + '\n')
|
||||
else:
|
||||
tmp.write_text('')
|
||||
tmp.replace(JOURNAL)
|
||||
|
||||
|
||||
def record_trades(all_trades: dict, *, base_dt: str | None = None, quiet: bool = False, tag: str = 'collect') -> dict:
|
||||
"""이미 받은 ka10170 응답({label: [trades]})을 jsonl에 적재 (idempotent).
|
||||
`(iso_date, account in all_trades)` 단위 재적재 — 다른 계좌는 영향 X.
|
||||
자산웹 페이지 RENDER hook, fill_watcher 등 ka10170 추가 호출 회피 경로에서 사용."""
|
||||
now = datetime.now(KST)
|
||||
if base_dt is None:
|
||||
# 휴장일/주말엔 ka10170이 직전 영업일 데이터를 반환하므로 오늘 날짜로 적재하면 중복.
|
||||
# base_dt 명시 호출(collect --date / --force)은 의도적이라 가드 해제.
|
||||
if not is_market_open(now):
|
||||
if not quiet:
|
||||
print(f'[{tag}] {now.strftime("%Y-%m-%d")} 휴장일/주말 — record_trades skip')
|
||||
return {'skipped': True, 'date': now.strftime('%Y-%m-%d')}
|
||||
base_dt = now.strftime('%Y%m%d')
|
||||
iso_date = f'{base_dt[:4]}-{base_dt[4:6]}-{base_dt[6:]}'
|
||||
collected_at = now.isoformat()
|
||||
|
||||
existing = _load_all()
|
||||
keep = [
|
||||
r for r in existing
|
||||
if not (r.get('date') == iso_date and r.get('account') in all_trades)
|
||||
]
|
||||
new_rows: list[dict] = []
|
||||
for label, trades in all_trades.items():
|
||||
for t in trades:
|
||||
new_rows.append({
|
||||
'date': iso_date,
|
||||
'account': label,
|
||||
'owner': _owner(label),
|
||||
**t,
|
||||
'collected_at': collected_at,
|
||||
})
|
||||
_save_all(keep + new_rows)
|
||||
counts = {label: len(trades) for label, trades in all_trades.items()}
|
||||
if not quiet:
|
||||
total = sum(counts.values())
|
||||
breakdown = ' '.join(f'{k}={v}' for k, v in counts.items())
|
||||
print(f'[{tag}] {iso_date} total={total} {breakdown}')
|
||||
return {'date': iso_date, 'counts': counts}
|
||||
|
||||
|
||||
def collect(base_dt: str | None = None, *, force: bool = False, quiet: bool = False) -> dict:
|
||||
"""ka10170 4계좌 → jsonl append (idempotent)."""
|
||||
now = datetime.now(KST)
|
||||
if base_dt is None:
|
||||
base_dt = now.strftime('%Y%m%d')
|
||||
iso_date = f'{base_dt[:4]}-{base_dt[4:6]}-{base_dt[6:]}'
|
||||
|
||||
target = datetime.strptime(base_dt, '%Y%m%d').replace(tzinfo=KST)
|
||||
if not force and not is_market_open(target):
|
||||
if not quiet:
|
||||
print(f'[skip] {iso_date} 휴장일/주말 — 수집 안 함 (--force 로 강제)')
|
||||
return {'skipped': True, 'date': iso_date}
|
||||
|
||||
all_trades = kw.get_trade_journal_all(base_dt=base_dt)
|
||||
return record_trades(all_trades, base_dt=base_dt, quiet=quiet, tag='collect')
|
||||
|
||||
|
||||
def seed_initial(*, seed_date: str | None = None, quiet: bool = False) -> dict:
|
||||
"""현재 4계좌 보유종목 → trade_journal 시드 행 적재.
|
||||
|
||||
키움이 기간 거래내역 API를 제공하지 않아, jsonl 적재 시작일(2026-05-13) 이전의
|
||||
매수 이력은 "지금 평단가 × 보유수량"으로 단일 행 압축한다.
|
||||
|
||||
시드 수량 = (현재 보유) - (seed_date 이후 jsonl 매수 합) + (seed_date 이후 jsonl 매도 합)
|
||||
- (오늘 ka10170 매수) + (오늘 ka10170 매도)
|
||||
시드 단가 = avg_price (현재 평단가 — 가중평균이라 과거 단가와 다를 수 있음)
|
||||
|
||||
seed=true 플래그로 ka10170 일자 행과 구분.
|
||||
같은 (date, account, code, seed=true) 행이 이미 있으면 skip → idempotent.
|
||||
|
||||
seed_date 인자로 적재 날짜 지정 가능 (기본: 오늘 KST). 실거래 행 이전 날짜로
|
||||
박으면 모달에서 시각적으로 명확히 분리됨. 이 경우 seed_date 이후 jsonl 거래 효과도
|
||||
되돌려 시드 시점 실제 보유수량을 추정한다.
|
||||
"""
|
||||
now = datetime.now(KST)
|
||||
if seed_date is None:
|
||||
seed_date = now.strftime('%Y-%m-%d')
|
||||
collected_at = now.isoformat()
|
||||
|
||||
positions = kw.get_positions_all() # {label: [positions]}
|
||||
|
||||
existing = _load_all()
|
||||
seen = {
|
||||
(r['date'], r['account'], r['code'])
|
||||
for r in existing if r.get('seed') is True
|
||||
}
|
||||
# seed_date 초과, 오늘 미만의 비-시드 행 효과를 (account, code)별로 집계
|
||||
# (오늘 거래는 ka10170 tdy_buyq/tdy_sellq 로 처리 → 중복 방지)
|
||||
today_str = now.strftime('%Y-%m-%d')
|
||||
post_seed: dict[tuple[str, str], dict[str, int]] = {}
|
||||
for r in existing:
|
||||
if r.get('seed') is True:
|
||||
continue
|
||||
if r['date'] <= seed_date or r['date'] >= today_str:
|
||||
continue
|
||||
key = (r['account'], r['code'])
|
||||
agg = post_seed.setdefault(key, {'buy': 0, 'sell': 0})
|
||||
agg['buy'] += int(r.get('buy_qty') or 0)
|
||||
agg['sell'] += int(r.get('sell_qty') or 0)
|
||||
|
||||
new_rows: list[dict] = []
|
||||
skipped_zero = 0
|
||||
skipped_dup = 0
|
||||
for label, items in positions.items():
|
||||
for p in items:
|
||||
agg = post_seed.get((label, p['code']), {'buy': 0, 'sell': 0})
|
||||
seed_qty = (
|
||||
p['qty']
|
||||
- p['tdy_buyq'] + p['tdy_sellq']
|
||||
- agg['buy'] + agg['sell']
|
||||
)
|
||||
if seed_qty <= 0:
|
||||
skipped_zero += 1
|
||||
continue
|
||||
key = (seed_date, label, p['code'])
|
||||
if key in seen:
|
||||
skipped_dup += 1
|
||||
continue
|
||||
new_rows.append({
|
||||
'date': seed_date,
|
||||
'account': label,
|
||||
'owner': _owner(label),
|
||||
'code': p['code'],
|
||||
'name': p['name'],
|
||||
'buy_qty': seed_qty,
|
||||
'buy_avg': p['avg_price'],
|
||||
'buy_amt': seed_qty * p['avg_price'],
|
||||
'sell_qty': 0,
|
||||
'sell_avg': 0,
|
||||
'sell_amt': 0,
|
||||
'pl_amt': 0,
|
||||
'cmsn_tax': 0,
|
||||
'prft_rt': 0.0,
|
||||
'seed': True,
|
||||
'collected_at': collected_at,
|
||||
})
|
||||
|
||||
if new_rows:
|
||||
_save_all(existing + new_rows)
|
||||
if not quiet:
|
||||
per_label = {l: 0 for l in positions}
|
||||
for r in new_rows:
|
||||
per_label[r['account']] += 1
|
||||
breakdown = ' '.join(f'{k}={v}' for k, v in per_label.items())
|
||||
print(f'[seed] {seed_date} added={len(new_rows)} {breakdown}'
|
||||
+ (f' (skip_zero={skipped_zero} skip_dup={skipped_dup})' if (skipped_zero or skipped_dup) else ''))
|
||||
return {'added': len(new_rows), 'skipped_zero': skipped_zero, 'skipped_dup': skipped_dup}
|
||||
|
||||
|
||||
def _print_rows(rows: list[dict]) -> None:
|
||||
print(f'{"date":<12}{"account":<14}{"code":<10}{"name":<16}{"buy":>16}{"sell":>16}{"pl":>12}')
|
||||
total_pl = 0
|
||||
for r in rows:
|
||||
marker = '*' if r.get('seed') else ' '
|
||||
buy = f'{r["buy_qty"]}@{r["buy_avg"]:,}' if r['buy_qty'] else '-'
|
||||
sell = f'{r["sell_qty"]}@{r["sell_avg"]:,}' if r['sell_qty'] else '-'
|
||||
print(f'{r["date"]:<12}{r["account"]:<14}{r["code"]:<10}{marker}{r["name"]:<15}{buy:>16}{sell:>16}{r["pl_amt"]:>12,}')
|
||||
total_pl += r['pl_amt']
|
||||
print(f'\n실현손익 합계: {total_pl:,}원 ({len(rows)}건, *=시드)')
|
||||
|
||||
|
||||
def show(code_or_name: str) -> None:
|
||||
rows = _load_all()
|
||||
q = code_or_name.strip()
|
||||
matched = [r for r in rows if r.get('code') == q or r.get('name') == q]
|
||||
if not matched:
|
||||
matched = [r for r in rows if q in (r.get('name') or '')]
|
||||
if not matched:
|
||||
print(f'[show] 일치 기록 없음: {q}')
|
||||
return
|
||||
matched.sort(key=lambda r: (r['date'], r['account']))
|
||||
print(f'[show] {q} — {len(matched)}건')
|
||||
_print_rows(matched)
|
||||
|
||||
|
||||
def query(*, date_from: str | None, date_to: str | None, account: str | None, code: str | None) -> None:
|
||||
rows = _load_all()
|
||||
if date_from:
|
||||
rows = [r for r in rows if r['date'] >= date_from]
|
||||
if date_to:
|
||||
rows = [r for r in rows if r['date'] <= date_to]
|
||||
if account:
|
||||
rows = [r for r in rows if r['account'] == account]
|
||||
if code:
|
||||
rows = [r for r in rows if r['code'] == code or r['name'] == code]
|
||||
rows.sort(key=lambda r: (r['date'], r['account'], r['code']))
|
||||
if not rows:
|
||||
print('[query] 결과 없음')
|
||||
return
|
||||
_print_rows(rows)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description='종목별 매매기록 (ka10170 적재·조회)')
|
||||
sub = p.add_subparsers(dest='cmd', required=True)
|
||||
|
||||
pc = sub.add_parser('collect', help='ka10170 4계좌 수집 → jsonl 적재')
|
||||
pc.add_argument('--date', help='YYYYMMDD (기본: 오늘 KST)')
|
||||
pc.add_argument('--force', action='store_true', help='휴장일/주말 강제 수집')
|
||||
pc.add_argument('--quiet', action='store_true')
|
||||
|
||||
psd = sub.add_parser('seed', help='4계좌 보유종목 → 현재 평단가로 시드 적재 (최초 1회)')
|
||||
psd.add_argument('--date', help='YYYY-MM-DD (기본: 오늘 KST)')
|
||||
|
||||
ps = sub.add_parser('show', help='종목별 거래내역')
|
||||
ps.add_argument('code_or_name')
|
||||
|
||||
pq = sub.add_parser('query', help='기간/계좌 필터')
|
||||
pq.add_argument('--from', dest='date_from')
|
||||
pq.add_argument('--to', dest='date_to')
|
||||
pq.add_argument('--account')
|
||||
pq.add_argument('--code')
|
||||
|
||||
args = p.parse_args(argv)
|
||||
if args.cmd == 'collect':
|
||||
collect(base_dt=args.date, force=args.force, quiet=args.quiet)
|
||||
elif args.cmd == 'seed':
|
||||
seed_initial(seed_date=args.date)
|
||||
elif args.cmd == 'show':
|
||||
show(args.code_or_name)
|
||||
elif args.cmd == 'query':
|
||||
query(date_from=args.date_from, date_to=args.date_to, account=args.account, code=args.code)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,531 @@
|
||||
#!/usr/bin/env python3
|
||||
"""워치리스트(behive_watchlist.json) 시세 감시 → buy/target/stop 조건 트리거 → 레이 텔레그램 알림.
|
||||
|
||||
LLM을 깨우지 않음. 미보유 종목 시세는 ka10095 batch 1콜(단건 ka10001 fallback), 보유 종목은 kt00018 결과 재활용.
|
||||
한번 알린 조건은 수동 리셋(웹뷰 '알림 리셋' 버튼) 전까지 재알림 X. 장외 시간에는 early exit.
|
||||
|
||||
필터링:
|
||||
- 미보유 종목: buy 레벨만 알림. 최초 매수구간 진입 1회만 (_unheld_done 마커),
|
||||
이후 다른 buy level 진입은 무시. target/stop은 매도 신호라 무의미.
|
||||
- 보유 종목: buy는 평단 미만 levels(추가매수)만, 각 level 별도 dedup.
|
||||
target/stop 도달 시 각각 1회씩.
|
||||
|
||||
state 포맷 (state/watchlist_alerts.json):
|
||||
{"alerts": {"서남": ["buy@4300", "_unheld_done", "target", "stop"], ...}}
|
||||
|
||||
Usage:
|
||||
python3 watchlist_monitor.py check # 1회 감시 (cron 용)
|
||||
python3 watchlist_monitor.py check --force # 장외 시간에도 실행 (테스트용)
|
||||
python3 watchlist_monitor.py dry-run # 조건 매칭만 출력, 텔레그램 발송 없음
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import html
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone, timedelta
|
||||
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
|
||||
|
||||
STATE_DIR = WORKSPACE / 'state'
|
||||
WATCHLIST = STATE_DIR / 'behive_watchlist.json'
|
||||
ALERTS_STATE = STATE_DIR / 'watchlist_alerts.json'
|
||||
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
|
||||
|
||||
TELEGRAM_ACCOUNT = 'stock' # 레이 봇
|
||||
EMAIL_RECIPIENT = 'mini.snowoyh@gmail.com'
|
||||
BUY_PROXIMITY = 1.05 # 매입가 5% 이내 접근 시 'buy' 트리거
|
||||
MARKET_OPEN = (9, 0)
|
||||
MARKET_CLOSE = (15, 45) # 15:30 장 마감 후 최종 체결 포함 버퍼
|
||||
|
||||
|
||||
def load_json(path: Path, default):
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except Exception:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path: Path, data):
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def alerts_lock():
|
||||
"""state/watchlist_alerts.json 동시 쓰기 직렬화. monitor cron과 web 리셋 버튼 race 방지."""
|
||||
lock_path = ALERTS_STATE.with_suffix(ALERTS_STATE.suffix + '.lock')
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(lock_path, 'a')
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
|
||||
def load_alerts_state() -> dict:
|
||||
"""alerts state 로드 + 레거시(date-keyed) 포맷 마이그레이션.
|
||||
|
||||
레거시: {"YYYY-MM-DD": {stock: [conds]}} → 모든 날짜의 stock entries 머지
|
||||
신규: {"alerts": {stock: [conds]}}
|
||||
"""
|
||||
raw = load_json(ALERTS_STATE, {})
|
||||
if not isinstance(raw, dict):
|
||||
return {'alerts': {}}
|
||||
if isinstance(raw.get('alerts'), dict):
|
||||
return {'alerts': dict(raw['alerts'])}
|
||||
merged: dict[str, list[str]] = {}
|
||||
for v in raw.values():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
for stock, conds in v.items():
|
||||
if not isinstance(conds, list):
|
||||
continue
|
||||
bucket = merged.setdefault(stock, [])
|
||||
for c in conds:
|
||||
if isinstance(c, str) and c not in bucket:
|
||||
bucket.append(c)
|
||||
return {'alerts': merged}
|
||||
|
||||
|
||||
def is_market_hours() -> bool:
|
||||
now = datetime.now(KST)
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
# KRX 휴장일도 거래 없음 — state/market_holidays.json (holiday_sync 갱신) 참조.
|
||||
# 데이터 파일 누락이나 import 실패 시엔 평소대로 진행 (false-positive 회피).
|
||||
try:
|
||||
from holiday_sync import is_holiday_today
|
||||
if is_holiday_today():
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
mins = now.hour * 60 + now.minute
|
||||
open_m = MARKET_OPEN[0] * 60 + MARKET_OPEN[1]
|
||||
close_m = MARKET_CLOSE[0] * 60 + MARKET_CLOSE[1]
|
||||
return open_m <= mins <= close_m
|
||||
|
||||
|
||||
def send_telegram(text: str) -> bool:
|
||||
cfg = json.loads(CONFIG_PATH.read_text())
|
||||
acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
|
||||
token = acct['botToken']
|
||||
chat_ids = acct.get('allowFrom') or []
|
||||
if not chat_ids:
|
||||
print('no telegram chat_ids', file=sys.stderr)
|
||||
return False
|
||||
url = f'https://api.telegram.org/bot{token}/sendMessage'
|
||||
ok = True
|
||||
for chat_id in chat_ids:
|
||||
data = urllib.parse.urlencode({
|
||||
'chat_id': chat_id,
|
||||
'text': text[:4000],
|
||||
'disable_web_page_preview': 'true',
|
||||
}).encode()
|
||||
try:
|
||||
req = urllib.request.Request(url, data=data, method='POST')
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
if r.status != 200:
|
||||
ok = False
|
||||
print(f'telegram HTTP {r.status}', file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f'telegram error: {e}', file=sys.stderr)
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
def send_mail(subject: str, html_body: str) -> bool:
|
||||
"""워치리스트 상세 알림을 이메일 한 통으로 발송."""
|
||||
cmd = ['gog', 'gmail', 'send', '--to', EMAIL_RECIPIENT, '--subject', subject, '--body-html', html_body]
|
||||
p = subprocess.run(cmd, text=True, capture_output=True)
|
||||
if p.returncode != 0:
|
||||
print(p.stderr.strip() or p.stdout.strip() or 'gog gmail send failed', file=sys.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_held_info() -> dict[str, dict]:
|
||||
"""보유 종목 {code: {cur_price, avg_price}} — kt00018 재활용."""
|
||||
info: dict[str, dict] = {}
|
||||
try:
|
||||
for positions in kc.get_positions_all().values():
|
||||
for p in positions:
|
||||
code = (p.get('code') or '').strip()
|
||||
if not code:
|
||||
continue
|
||||
info[code] = {
|
||||
'cur_price': int(p.get('cur_price') or 0),
|
||||
'avg_price': int(p.get('avg_price') or 0),
|
||||
}
|
||||
except Exception as e:
|
||||
print(f'[warn] positions 조회 실패 — ka10001 단독 사용: {e}', file=sys.stderr)
|
||||
return info
|
||||
|
||||
|
||||
def _buy_levels(entry: dict) -> list[int]:
|
||||
"""buy.levels 우선, 없으면 buy.primary를 단일 레벨로 사용."""
|
||||
buy = entry.get('buy')
|
||||
if not isinstance(buy, dict):
|
||||
return []
|
||||
levels = buy.get('levels') or []
|
||||
out = [int(lv) for lv in levels if isinstance(lv, (int, float)) and lv > 0]
|
||||
if out:
|
||||
return out
|
||||
primary = buy.get('primary')
|
||||
if isinstance(primary, (int, float)) and primary > 0:
|
||||
return [int(primary)]
|
||||
return []
|
||||
|
||||
|
||||
def evaluate(entry: dict, price: int, is_held: bool, avg_price: int | None) -> list[str]:
|
||||
"""충족된 조건 반환.
|
||||
|
||||
- 미보유: buy 레벨만 평가 (target/stop 무시) → 'buy@<price>' 키
|
||||
- 보유: target/stop 평가, buy는 평단 미만 레벨만 (추가매수 타이밍)
|
||||
"""
|
||||
triggered: list[str] = []
|
||||
for lv in _buy_levels(entry):
|
||||
if is_held and avg_price and lv >= avg_price:
|
||||
continue # 이미 매수한 가격대 — 알림 불필요
|
||||
if price <= lv * BUY_PROXIMITY:
|
||||
triggered.append(f'buy@{lv}')
|
||||
if is_held:
|
||||
target = entry.get('target')
|
||||
stop = entry.get('stop')
|
||||
if isinstance(target, dict):
|
||||
tl = target.get('low')
|
||||
if isinstance(tl, (int, float)) and tl > 0 and price >= tl:
|
||||
triggered.append('target')
|
||||
if isinstance(stop, dict):
|
||||
sv = stop.get('value')
|
||||
if isinstance(sv, (int, float)) and sv > 0 and price <= sv:
|
||||
triggered.append('stop')
|
||||
return triggered
|
||||
|
||||
|
||||
def fmt_price(v) -> str:
|
||||
try:
|
||||
return f'{int(v):,}원'
|
||||
except Exception:
|
||||
return str(v)
|
||||
|
||||
|
||||
RANK_LABELS = ['1차', '2차', '3차', '4차', '5차']
|
||||
|
||||
|
||||
def build_alert(entry: dict, condition: str, price: int, change: int = 0, pct: float = 0.0, buy_level: int | None = None) -> str:
|
||||
stock = entry.get('stock', '?')
|
||||
buy_raw = ((entry.get('buy') or {}).get('raw')) if entry.get('buy') else None
|
||||
target_raw = ((entry.get('target') or {}).get('raw')) if entry.get('target') else None
|
||||
stop_raw = ((entry.get('stop') or {}).get('raw')) if entry.get('stop') else None
|
||||
upside = entry.get('upside_pct')
|
||||
if condition == 'buy' and buy_level is not None:
|
||||
levels = _buy_levels(entry)
|
||||
rank_prefix = ''
|
||||
if len(levels) > 1 and buy_level in levels:
|
||||
idx = levels.index(buy_level)
|
||||
if 0 <= idx < len(RANK_LABELS):
|
||||
rank_prefix = f'{RANK_LABELS[idx]} '
|
||||
header = f'#{stock} {rank_prefix}매수 구간 진입 ({buy_level:,}원)'
|
||||
else:
|
||||
header_map = {
|
||||
'target': f'#{stock} 목표가 도달',
|
||||
'stop': f'⚠️ #{stock} 손절가 이탈',
|
||||
}
|
||||
header = header_map.get(condition, f'#{stock}')
|
||||
cur_line = f'{price:,}원'
|
||||
if change or pct:
|
||||
cur_line += f' ({change:+,}원, {pct:+.2f}%)'
|
||||
lines = [
|
||||
f'[워치리스트] {datetime.now(KST).strftime("%m/%d %H:%M")}',
|
||||
header,
|
||||
f'• 현재가: {cur_line}',
|
||||
]
|
||||
if buy_raw:
|
||||
lines.append(f'• 매입가: {buy_raw}')
|
||||
if target_raw:
|
||||
suffix = ''
|
||||
if upside is not None:
|
||||
sign = '+' if upside >= 0 else ''
|
||||
suffix = f' ({sign}{upside}%)'
|
||||
lines.append(f'• 목표가: {target_raw}{suffix}')
|
||||
if stop_raw:
|
||||
lines.append(f'• 손절가: {stop_raw}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _condition_label(condition_key: str) -> str:
|
||||
if condition_key.startswith('buy@'):
|
||||
try:
|
||||
return f'매수구간 진입 {int(condition_key.split("@", 1)[1]):,}원'
|
||||
except Exception:
|
||||
return '매수구간 진입'
|
||||
if condition_key == 'target':
|
||||
return '목표가 도달'
|
||||
if condition_key == 'stop':
|
||||
return '손절가 이탈'
|
||||
return condition_key
|
||||
|
||||
|
||||
def build_summary(records: list[dict], mail_ok: bool | None = None) -> str:
|
||||
"""텔레그램용: 조건/종목/현재가만 짧게 묶어 발송."""
|
||||
ts = datetime.now(KST).strftime('%m/%d %H:%M')
|
||||
groups = [
|
||||
('buy', '매수구간 진입'),
|
||||
('target', '목표가 도달'),
|
||||
('stop', '손절가 이탈'),
|
||||
]
|
||||
lines = [f'[워치리스트 요약] {ts}', f'총 {len(records)}건 감지']
|
||||
for prefix, label in groups:
|
||||
items = [r for r in records if r['condition_key'].startswith('buy@') if prefix == 'buy'] if prefix == 'buy' else [r for r in records if r['condition_key'] == prefix]
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f'\n[{label}]')
|
||||
for r in items:
|
||||
cur = f"{r['price']:,}원"
|
||||
pct = r.get('pct')
|
||||
if isinstance(pct, (int, float)) and pct:
|
||||
cur += f' ({pct:+.2f}%)'
|
||||
extra = ''
|
||||
if r['condition_key'].startswith('buy@'):
|
||||
extra = f" / 기준 {r.get('buy_level', 0):,}원"
|
||||
lines.append(f"• {r['stock']}: {cur}{extra}")
|
||||
if mail_ok is True:
|
||||
lines.append('\n상세 내용은 메일로 묶어서 보냈습니다.')
|
||||
elif mail_ok is False:
|
||||
lines.append('\n⚠️ 상세 메일 발송 실패 — 로그 확인 필요')
|
||||
else:
|
||||
lines.append('\n상세 내용은 메일로 발송 예정입니다.')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def build_detail_email(records: list[dict]) -> str:
|
||||
"""이메일용: 기존 개별 알림 상세를 카드 형태로 묶음."""
|
||||
esc = html.escape
|
||||
ts = datetime.now(KST).strftime('%Y-%m-%d %H:%M')
|
||||
parts = [
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",sans-serif;font-size:14px;color:#222;max-width:720px;line-height:1.55;">',
|
||||
f'<div style="margin-bottom:16px;color:#444;">관리자님, 워치리스트 조건 감지 {len(records)}건 상세입니다. <span style="color:#888;">{esc(ts)}</span></div>',
|
||||
]
|
||||
for r in records:
|
||||
e = r['entry']
|
||||
buy_raw = ((e.get('buy') or {}).get('raw')) if e.get('buy') else None
|
||||
target_raw = ((e.get('target') or {}).get('raw')) if e.get('target') else None
|
||||
stop_raw = ((e.get('stop') or {}).get('raw')) if e.get('stop') else None
|
||||
summary = e.get('summary') or []
|
||||
notes = e.get('notes') or []
|
||||
video = e.get('video') or {}
|
||||
cond = _condition_label(r['condition_key'])
|
||||
color = '#d24f4f' if r['condition_key'] != 'stop' else '#1565c0'
|
||||
cur_line = f"{r['price']:,}원"
|
||||
if r.get('change') or r.get('pct'):
|
||||
cur_line += f" ({r.get('change', 0):+,}원, {r.get('pct', 0.0):+.2f}%)"
|
||||
parts.append('<div style="border:1px solid #e5e5e5;border-radius:8px;padding:16px 18px;margin-bottom:16px;background:#fafafa;">')
|
||||
parts.append(f'<div style="font-size:16px;font-weight:700;margin-bottom:4px;">{esc(r["stock"])} <span style="font-size:12px;color:#666;font-weight:400;">{esc(e.get("code") or "")}</span></div>')
|
||||
parts.append(f'<div style="font-weight:700;color:{color};margin-bottom:10px;">{esc(cond)}</div>')
|
||||
parts.append('<table style="width:100%;border-collapse:collapse;background:#fff;border:1px solid #eee;margin:10px 0 14px;">')
|
||||
rows = [('현재가', cur_line)]
|
||||
if buy_raw:
|
||||
rows.append(('매입가', buy_raw))
|
||||
if target_raw:
|
||||
rows.append(('목표가', target_raw))
|
||||
if stop_raw:
|
||||
rows.append(('손절가', stop_raw))
|
||||
for label, value in rows:
|
||||
parts.append(f'<tr><td style="color:#666;padding:6px 10px;width:90px;border-bottom:1px solid #f0f0f0;">{esc(label)}</td><td style="padding:6px 10px;border-bottom:1px solid #f0f0f0;font-weight:500;">{esc(value)}</td></tr>')
|
||||
parts.append('</table>')
|
||||
if summary:
|
||||
parts.append('<div style="font-weight:600;margin:10px 0 4px;">요약</div><ul style="margin:4px 0 8px 0;padding-left:20px;">')
|
||||
for item in summary[:6]:
|
||||
parts.append(f'<li>{esc(item)}</li>')
|
||||
parts.append('</ul>')
|
||||
if notes:
|
||||
parts.append('<div style="font-weight:600;margin:10px 0 4px;">메모</div><ul style="margin:4px 0 8px 0;padding-left:20px;">')
|
||||
for item in notes[:6]:
|
||||
parts.append(f'<li>{esc(item)}</li>')
|
||||
parts.append('</ul>')
|
||||
if video.get('url'):
|
||||
parts.append(f'<div style="color:#888;font-size:12px;margin-top:10px;border-top:1px dashed #ddd;padding-top:8px;">출처: <a href="{esc(video.get("url", ""))}" style="color:#06c;">{esc(video.get("title") or video.get("url"))}</a></div>')
|
||||
parts.append('</div>')
|
||||
parts.append('</div>')
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def run(dry: bool = False, force: bool = False) -> int:
|
||||
if not force and not is_market_hours():
|
||||
print(f'장외 시간 — skip ({datetime.now(KST).strftime("%Y-%m-%d %H:%M")})')
|
||||
return 0
|
||||
watchlist = load_json(WATCHLIST, {})
|
||||
if not watchlist:
|
||||
print('watchlist 비어있음')
|
||||
return 0
|
||||
alerts_state = load_alerts_state()
|
||||
alerts_by_stock: dict[str, list[str]] = alerts_state.setdefault('alerts', {})
|
||||
|
||||
held_info = get_held_info()
|
||||
triggered_total = 0
|
||||
pending_records: list[dict] = []
|
||||
watchlist_dirty = False
|
||||
|
||||
# 미보유 종목은 ka10095 batch 1콜로 일괄 시세 조회 (단건 ka10001 × N 대비 압도적으로 빠르고 rate limit 부담 없음).
|
||||
non_held_codes = [
|
||||
(e.get('code') or '').strip()
|
||||
for s, e in watchlist.items()
|
||||
if isinstance(e, dict) and e.get('status') != 'pending_delete' and (e.get('code') or '').strip()
|
||||
and (e.get('code') or '').strip() not in held_info
|
||||
]
|
||||
batch_quotes: dict[str, dict] = {}
|
||||
if non_held_codes:
|
||||
try:
|
||||
batch_quotes = kc.get_watchlist_quotes(non_held_codes)
|
||||
except Exception as e:
|
||||
print(f'[warn] ka10095 batch 실패, 단건 fallback: {e}', file=sys.stderr)
|
||||
|
||||
for stock, entry in list(watchlist.items()):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get('status') == 'pending_delete':
|
||||
continue
|
||||
code = (entry.get('code') or '').strip()
|
||||
if not code:
|
||||
try:
|
||||
info = kc.resolve_stock_code(stock)
|
||||
code = info.get('code', '')
|
||||
if code:
|
||||
entry['code'] = code
|
||||
watchlist_dirty = True
|
||||
except Exception as e:
|
||||
print(f'[{stock}] 종목코드 매핑 실패: {e} — 스킵', file=sys.stderr)
|
||||
continue
|
||||
held = held_info.get(code)
|
||||
is_held = held is not None
|
||||
avg_price = held.get('avg_price') if held else None
|
||||
price = held.get('cur_price') if held else None
|
||||
change = 0
|
||||
pct = 0.0
|
||||
if not price:
|
||||
bq = batch_quotes.get(code)
|
||||
if bq and bq.get('price'):
|
||||
price = bq['price']
|
||||
change = bq.get('change', 0)
|
||||
pct = bq.get('change_pct', 0.0)
|
||||
else:
|
||||
try:
|
||||
q = kc.get_stock_quote(code)
|
||||
price = q.get('price', 0)
|
||||
change = q.get('change', 0)
|
||||
pct = q.get('change_pct', 0.0)
|
||||
except Exception as e:
|
||||
print(f'[{stock}/{code}] quote 실패: {e}', file=sys.stderr)
|
||||
continue
|
||||
if not isinstance(price, (int, float)) or price <= 0:
|
||||
continue
|
||||
conds = evaluate(entry, int(price), is_held, avg_price)
|
||||
already = set(alerts_by_stock.get(stock, []))
|
||||
new_conds = [c for c in conds if c not in already]
|
||||
# 미보유 상태에서 한 번이라도 buy 알림 발사된 적 있으면(_unheld_done) 이후 buy 알림 전부 차단.
|
||||
if not is_held and '_unheld_done' in already:
|
||||
new_conds = [c for c in new_conds if not c.startswith('buy@')]
|
||||
buy_conds = [c for c in new_conds if c.startswith('buy@')]
|
||||
other_conds = [c for c in new_conds if not c.startswith('buy@')]
|
||||
if len(buy_conds) > 1:
|
||||
# 여러 buy 레벨이 한 번에 걸리면 가장 높은(먼저 대응할) 레벨 1개만 알림.
|
||||
buy_conds = [max(buy_conds, key=lambda c: int(c.split('@', 1)[1]))]
|
||||
for c in [*buy_conds, *other_conds]:
|
||||
if c.startswith('buy@'):
|
||||
lv = int(c.split('@', 1)[1])
|
||||
text = build_alert(entry, 'buy', int(price), int(change), float(pct), buy_level=lv)
|
||||
else:
|
||||
lv = None
|
||||
text = build_alert(entry, c, int(price), int(change), float(pct))
|
||||
if dry:
|
||||
print('--- DRY ---')
|
||||
print(text)
|
||||
triggered_total += 1
|
||||
else:
|
||||
pending_records.append({
|
||||
'stock': stock,
|
||||
'entry': entry,
|
||||
'condition_key': c,
|
||||
'buy_level': lv,
|
||||
'price': int(price),
|
||||
'change': int(change),
|
||||
'pct': float(pct),
|
||||
'is_held': is_held,
|
||||
})
|
||||
|
||||
if dry:
|
||||
if triggered_total:
|
||||
print('--- SUMMARY ---')
|
||||
# dry-run에서는 위에서 개별 상세를 이미 출력하므로 요약 포맷만 추가 점검.
|
||||
# pending_records는 실제 발송 모드에서만 채운다.
|
||||
print(f'done. watched={len(watchlist)} triggered={triggered_total}')
|
||||
return 0
|
||||
|
||||
if pending_records:
|
||||
subject = f'[워치리스트 조건 감지] {len(pending_records)}건 — {datetime.now(KST).strftime("%Y-%m-%d %H:%M")}'
|
||||
mail_ok = send_mail(subject, build_detail_email(pending_records))
|
||||
summary = build_summary(pending_records, mail_ok=mail_ok)
|
||||
if send_telegram(summary):
|
||||
# 발송 직후 락 잡고 read-modify-write — web 리셋 버튼과의 race 차단.
|
||||
with alerts_lock():
|
||||
latest = load_alerts_state()
|
||||
latest_alerts = latest.setdefault('alerts', {})
|
||||
for r in pending_records:
|
||||
bucket = latest_alerts.setdefault(r['stock'], [])
|
||||
cond = r['condition_key']
|
||||
if cond not in bucket:
|
||||
bucket.append(cond)
|
||||
if not r['is_held'] and cond.startswith('buy@') and '_unheld_done' not in bucket:
|
||||
bucket.append('_unheld_done')
|
||||
triggered_total += 1
|
||||
print(f"alerted {r['stock']}:{cond} @ {r['price']:,}원")
|
||||
save_json(ALERTS_STATE, latest)
|
||||
else:
|
||||
# 알림 없을 때도 마이그레이션이 일어났을 수 있으니 한 번 저장(레거시 → 신규).
|
||||
with alerts_lock():
|
||||
save_json(ALERTS_STATE, alerts_state)
|
||||
|
||||
if watchlist_dirty:
|
||||
save_json(WATCHLIST, watchlist)
|
||||
print(f'done. watched={len(watchlist)} triggered={triggered_total}')
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else 'help'
|
||||
force = '--force' in sys.argv[2:]
|
||||
try:
|
||||
if cmd == 'check':
|
||||
return run(dry=False, force=force)
|
||||
if cmd == 'dry-run':
|
||||
return run(dry=True, force=True)
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
except Exception as e:
|
||||
import traceback
|
||||
tb = traceback.format_exc()
|
||||
print(f'[fatal] {e}\n{tb}', file=sys.stderr)
|
||||
try:
|
||||
send_telegram(f'⚠️ [watchlist_monitor] 실행 실패\n{type(e).__name__}: {e}')
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WISEreport(FnGuide 계열) 컨센서스 조회 (조회 전용, 캐시 우선).
|
||||
|
||||
FnGuide Snapshot(fnguide_client)이 못 주는 '시간축 컨센서스'를 보강한다:
|
||||
- 연도별 추정 재무 (IFRS연결, A 실적 + E 추정, 매출/영업이익/순이익/EPS/ROE + YoY)
|
||||
- 목표주가·추정치 리비전 추이 (최근 3개월 주간 — 상향/하향 모멘텀)
|
||||
- 어닝 서프라이즈 (시점별 컨센서스 vs 실적 괴리율)
|
||||
|
||||
데이터원: comp.wisereport.co.kr/company/ajax/{c1050001_data,cF5001}.aspx (JSON)
|
||||
|
||||
⚠️ 운영사가 FnGuide(WISEfn)로 fnguide_client와 동일 벤더. 같은 저작권·회색지대.
|
||||
→ 종목별 파일 캐시(기본 12h) + 보유·관심 종목 on-demand 조회로만 사용.
|
||||
|
||||
⚠️ 데이터 스케일 주의: 현재 피드에서 forward 추정 절대값(매출·EPS)이 과거 실적 대비
|
||||
크게 인플레이션돼 나오는 경우가 있다(현재가·목표가 동반). cutoff 가드는 두지 않고
|
||||
소스값 그대로 반환한다(메모리 규칙). 방향성 신호(리비전 %·서프라이즈 %)는 스케일 무관.
|
||||
|
||||
호출부는 예외에 의존하지 않는다 — 실패 시 None 반환.
|
||||
|
||||
CLI:
|
||||
python3 wisereport_client.py <code> [--fresh]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
CACHE_DIR = WORKSPACE / 'state' / 'wisereport_cache'
|
||||
CACHE_TTL_SEC = 12 * 3600
|
||||
|
||||
_BASE = 'https://comp.wisereport.co.kr/company/ajax'
|
||||
_UA = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36')
|
||||
_TIMEOUT = 8
|
||||
|
||||
# 서프라이즈/리비전 대상 지표 (acc_cd → 라벨)
|
||||
_SUP_ITEMS = {'121000': '매출액', '121500': '영업이익'}
|
||||
|
||||
|
||||
def _norm_code(code: str) -> str:
|
||||
s = ''.join(ch for ch in str(code) if ch.isdigit())
|
||||
return s.zfill(6)[-6:] if s else ''
|
||||
|
||||
|
||||
def _num(s) -> float | None:
|
||||
if s is None:
|
||||
return None
|
||||
if isinstance(s, (int, float)):
|
||||
return float(s)
|
||||
t = str(s).strip().replace(',', '')
|
||||
if not t or t in ('-', 'N/A'):
|
||||
return None
|
||||
try:
|
||||
return float(t)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _today_kst() -> str:
|
||||
return time.strftime('%Y%m%d', time.localtime())
|
||||
|
||||
|
||||
def _get_json(url: str, referer: str) -> dict | list | None:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': _UA, 'Referer': referer})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
||||
raw = resp.read()
|
||||
except Exception:
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw.decode('utf-8', 'replace'))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_annual(code6: str, sdt: str, ref: str) -> list[dict]:
|
||||
"""flag=2 → 연도별 추정 재무 (A 실적 + E 추정, IFRS연결)."""
|
||||
url = (f'{_BASE}/c1050001_data.aspx?cmp_cd={code6}&finGubun=MAIN&frq=0'
|
||||
f'&sDT={sdt}&chartType=svg&flag=2')
|
||||
d = _get_json(url, ref)
|
||||
rows = (d or {}).get('JsonData') if isinstance(d, dict) else None
|
||||
if not rows:
|
||||
return []
|
||||
out = []
|
||||
for r in rows:
|
||||
yymm = (r.get('YYMM') or '').strip() # "2025.12(A)" / "2026.12(E)"
|
||||
is_est = '(E)' in yymm
|
||||
period = yymm.replace('(A)', '').replace('(E)', '').replace('.', '/')
|
||||
out.append({
|
||||
'period': period,
|
||||
'is_estimate': is_est,
|
||||
'sales': _num(r.get('SALES')),
|
||||
'sales_yoy': _num(r.get('YOY')),
|
||||
'op': _num(r.get('OP')),
|
||||
'np': _num(r.get('NP')),
|
||||
'eps': _num(r.get('EPS')),
|
||||
'bps': _num(r.get('BPS')),
|
||||
'per': _num(r.get('PER')),
|
||||
'pbr': _num(r.get('PBR')),
|
||||
'roe': _num(r.get('ROE')),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _parse_revision(code6: str, sdt: str, yymm: str, ref: str) -> dict | None:
|
||||
"""cF5001 → 목표주가·추정치(EPS) 주간 추이 + 변화율 요약."""
|
||||
url = (f'{_BASE}/cF5001.aspx?cmp_cd={code6}&dt={sdt}&yymm={yymm}&frq=0'
|
||||
f'&acc_cd=121000&fingubun=MAIN&chartType=svg')
|
||||
d = _get_json(url, ref)
|
||||
if not isinstance(d, dict) or 'chart1' not in d:
|
||||
return None
|
||||
try:
|
||||
c = json.loads(d['chart1'])
|
||||
except Exception:
|
||||
return None
|
||||
dates = c.get('categories') or []
|
||||
tp = [x for x in (c.get('target_price') or []) if isinstance(x, (int, float))]
|
||||
est = [x for x in (c.get('select_item') or []) if isinstance(x, (int, float))]
|
||||
|
||||
def _chg(seq):
|
||||
if len(seq) >= 2 and seq[0]:
|
||||
return round((seq[-1] / seq[0] - 1) * 100, 1)
|
||||
return None
|
||||
|
||||
if not tp and not est:
|
||||
return None
|
||||
return {
|
||||
'item_name': c.get('select_item_name'),
|
||||
'unit': c.get('select_item_unit'),
|
||||
'dates': dates,
|
||||
'target_price': c.get('target_price') or [],
|
||||
'est_item': c.get('select_item') or [],
|
||||
'target_first': tp[0] if tp else None,
|
||||
'target_last': tp[-1] if tp else None,
|
||||
'target_change_pct': _chg(tp),
|
||||
'est_first': est[0] if est else None,
|
||||
'est_last': est[-1] if est else None,
|
||||
'est_change_pct': _chg(est),
|
||||
'n_points': len(dates),
|
||||
}
|
||||
|
||||
|
||||
def _parse_surprise(code6: str, sdt: str, ref: str) -> dict | None:
|
||||
"""flag=5 → 매출·영업이익 시점별 컨센서스 vs 실적 + 최신연도 서프라이즈%."""
|
||||
items: dict[str, dict] = {}
|
||||
years = None
|
||||
for acc_cd, label in _SUP_ITEMS.items():
|
||||
url = (f'{_BASE}/c1050001_data.aspx?cmp_cd={code6}&finGubun=MAIN&frq=0'
|
||||
f'&sDT={sdt}&chartType=svg&flag=5&acc_cd={acc_cd}')
|
||||
d = _get_json(url, ref)
|
||||
td = (d or {}).get('tableData') if isinstance(d, dict) else None
|
||||
rows = (td or {}).get('tableData') if isinstance(td, dict) else None
|
||||
if not rows:
|
||||
continue
|
||||
if years is None:
|
||||
hdr = (td.get('tableHeaderData') or [{}])[0]
|
||||
years = {k: hdr.get(k) for k in ('CNS_FY_2', 'CNS_FY_1', 'CNS_FY0', 'CNS_FY1')}
|
||||
# 최신 완료연도(FY0) 실적 + 발표직전(E) 서프라이즈%
|
||||
actual_fy0 = pre_surprise = pre_est = None
|
||||
for row in rows:
|
||||
qtr = (row.get('QTR') or '').strip()
|
||||
if qtr == '연간실적(A)':
|
||||
actual_fy0 = _num(row.get('FY0'))
|
||||
elif qtr == '발표직전(E)':
|
||||
pre_est = _num(row.get('FY0'))
|
||||
pre_surprise = _num(row.get('FY0_S')) # 괴리율 %
|
||||
items[label] = {
|
||||
'fy0_year': (years or {}).get('CNS_FY0'),
|
||||
'fy0_actual': actual_fy0,
|
||||
'fy0_consensus': pre_est,
|
||||
'fy0_surprise_pct': pre_surprise,
|
||||
}
|
||||
# 실데이터(연도 라벨 + 실적/서프라이즈) 하나도 없으면 None (ETF·없는종목).
|
||||
has_real = bool(years and any((years or {}).values())) and any(
|
||||
v.get('fy0_actual') is not None or v.get('fy0_surprise_pct') is not None
|
||||
for v in items.values()
|
||||
)
|
||||
if not has_real:
|
||||
return None
|
||||
return {'years': years, 'items': items}
|
||||
|
||||
|
||||
def _build(code6: str) -> dict | None:
|
||||
sdt = _today_kst()
|
||||
ref = f'https://comp.wisereport.co.kr/company/c1050001.aspx?cmp_cd={code6}'
|
||||
annual = _parse_annual(code6, sdt, ref)
|
||||
# cF5001 yymm: 첫 추정연도(E) 기준, 없으면 최신연도
|
||||
yymm = '000000'
|
||||
est_years = [a['period'] for a in annual if a.get('is_estimate')]
|
||||
if est_years:
|
||||
yymm = est_years[0].replace('/', '') # "2026/12" → "202612"
|
||||
elif annual:
|
||||
yymm = annual[-1]['period'].replace('/', '')
|
||||
revision = _parse_revision(code6, sdt, yymm, ref)
|
||||
surprise = _parse_surprise(code6, sdt, ref)
|
||||
|
||||
if not annual and not revision and not surprise:
|
||||
return None
|
||||
return {
|
||||
'code': code6,
|
||||
'fetched_at': time.strftime('%Y-%m-%dT%H:%M:%S+09:00', time.localtime()),
|
||||
'annual': annual, # IFRS연결, A+E
|
||||
'revision': revision, # 목표주가·추정EPS 추이
|
||||
'surprise': surprise, # 매출·영업이익 서프라이즈
|
||||
}
|
||||
|
||||
|
||||
def _cache_path(code6: str) -> Path:
|
||||
return CACHE_DIR / f'{code6}.json'
|
||||
|
||||
|
||||
def get_consensus(code: str, max_age_sec: int = CACHE_TTL_SEC,
|
||||
force: bool = False) -> dict | None:
|
||||
"""종목 컨센서스 dict 반환 (캐시 우선). 조회 불가 시 None — raise 안 함."""
|
||||
code6 = _norm_code(code)
|
||||
if not code6:
|
||||
return None
|
||||
cp = _cache_path(code6)
|
||||
if not force and cp.exists():
|
||||
try:
|
||||
if time.time() - cp.stat().st_mtime < max_age_sec:
|
||||
return json.loads(cp.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
data = _build(code6)
|
||||
if data is None:
|
||||
return None
|
||||
try:
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = cp.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False))
|
||||
tmp.replace(cp)
|
||||
except Exception:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
import re as _re
|
||||
|
||||
|
||||
def _clean_summary(html: str | None, max_bullets: int = 3) -> list[str]:
|
||||
"""COMMENT2(HTML) → bullet 텍스트 리스트. ▶/<br> 기준 분리, 태그 제거."""
|
||||
if not html:
|
||||
return []
|
||||
t = html.replace('\r\n', '\n')
|
||||
t = _re.sub(r'<br\s*/?>', '\n', t, flags=_re.I)
|
||||
t = _re.sub(r'<[^>]+>', '', t) # 태그 제거
|
||||
t = t.replace('▶', '\n')
|
||||
bullets = [b.strip(' \t·-') for b in _re.split(r'\n+', t)]
|
||||
bullets = [b for b in bullets if b]
|
||||
return bullets[:max_bullets]
|
||||
|
||||
|
||||
def _reports_cache_path(code6: str) -> Path:
|
||||
return CACHE_DIR / f'{code6}_reports.json'
|
||||
|
||||
|
||||
def get_reports(code: str, limit: int = 8, max_age_sec: int = 6 * 3600,
|
||||
force: bool = False) -> list[dict] | None:
|
||||
"""최근 증권사 분석리포트 목록. 조회 불가·없음 시 None — raise 안 함.
|
||||
|
||||
각 항목: {date, broker, broker_full, title, target, recomm,
|
||||
target_action, recomm_action, analyst, summary[]}
|
||||
리포트는 수시 갱신 → 캐시 6h. PDF 원문은 게이팅 가능성으로 제외(메타+요약만).
|
||||
"""
|
||||
code6 = _norm_code(code)
|
||||
if not code6:
|
||||
return None
|
||||
cp = _reports_cache_path(code6)
|
||||
if not force and cp.exists():
|
||||
try:
|
||||
if time.time() - cp.stat().st_mtime < max_age_sec:
|
||||
return json.loads(cp.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ref = f'https://comp.wisereport.co.kr/company/c1080001.aspx?cmp_cd={code6}'
|
||||
d = _get_json(f'{_BASE}/c1080001_data.aspx?cmp_cd={code6}', ref)
|
||||
rows = (d or {}).get('lists') if isinstance(d, dict) else None
|
||||
if not rows:
|
||||
return None
|
||||
out = []
|
||||
for r in rows[:limit]:
|
||||
title = (r.get('PRE_TITLE') or '') + (r.get('RPT_TITLE') or '')
|
||||
out.append({
|
||||
'date': (r.get('ANL_DT') or '').strip(),
|
||||
'broker': (r.get('BRK_NM_SHORT_KOR') or r.get('BRK_NM_KOR') or '').strip(),
|
||||
'broker_full': (r.get('BRK_NM_KOR') or '').strip(),
|
||||
'title': title.strip(),
|
||||
'target': (r.get('TARGET_PRC') or '').strip() or None,
|
||||
'recomm': (r.get('RECOMM') or '').strip() or None,
|
||||
'target_action': (r.get('PRC_ACTION_TYP_NM') or '').strip() or None,
|
||||
'recomm_action': (r.get('RECOMM_ACTION_TYP_NM') or '').strip() or None,
|
||||
'analyst': (r.get('ANL_NM_KOR') or '').strip() or None,
|
||||
'summary': _clean_summary(r.get('COMMENT2')),
|
||||
})
|
||||
if not out:
|
||||
return None
|
||||
try:
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = cp.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(out, ensure_ascii=False))
|
||||
tmp.replace(cp)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _cli() -> None:
|
||||
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
if not args:
|
||||
print('usage: python3 wisereport_client.py <code> [--fresh] [--reports]')
|
||||
sys.exit(1)
|
||||
if '--reports' in sys.argv:
|
||||
rs = get_reports(args[0], force=('--fresh' in sys.argv))
|
||||
print(json.dumps(rs, ensure_ascii=False, indent=2) if rs else f'{args[0]}: 리포트 없음')
|
||||
sys.exit(0)
|
||||
d = get_consensus(args[0], force=('--fresh' in sys.argv))
|
||||
if d is None:
|
||||
print(f'{args[0]}: 컨센서스 없음 (ETF/없는종목/조회실패)')
|
||||
sys.exit(0)
|
||||
print(json.dumps(d, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_cli()
|
||||
Reference in New Issue
Block a user