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:
hyowons
2026-06-04 15:39:41 +09:00
commit fed3526b20
199 changed files with 49671 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+846
View File
@@ -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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace("'", '&#39;'))
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
View File
@@ -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
View File
@@ -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:0020:00 (NXT 포함)
- 08:0009:00 NXT 단독 (NXT 미가능 종목 거부)
- 09:00:3015:20 KRX + NXT 동시 (SOR)
- 15:2015:30 KRX 단일가
- 15:3020: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
View File
@@ -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
File diff suppressed because it is too large Load Diff
+296
View File
@@ -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,&quot;Apple SD Gothic Neo&quot;,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()