Files
openclaw/agents/stock/workspace/scripts/trade_journal.py
T
hyowons 1bbb61520c auto: 일일 백업 2026-06-30 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 02:00:02 +09:00

386 lines
15 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 _advance(qty: int, avg: float, buy_qty: int, sell_qty: int, buy_avg: float) -> tuple[int, float]:
"""running 보유수량·평단 1스텝 진행.
매도는 **전 보유분 우선 소진**(평단 유지) → 잔여 당일 매수분을 가중평균 블렌딩.
이 순서가 '전일 보유 평단' 정의의 핵심 — 당일 매수가 매도 원가에 섞이지 않고,
매도로 원물이 다 빠지면 남는 건 당일 매수분 평단이 된다.
"""
consumed = min(sell_qty, qty)
qty -= consumed
rem_buy = buy_qty - (sell_qty - consumed) # 당일매수분 중 매도로 안 빠진 잔량
if rem_buy > 0:
avg = (qty * avg + rem_buy * buy_avg) / (qty + rem_buy)
qty += rem_buy
qty = max(qty, 0)
if qty == 0:
avg = 0.0 # 전량 청산 → 평단 리셋 (이후 순수 데이트레이딩을 보유분으로 오인 방지)
return qty, avg
def _apply_rebase(row: dict, sell_avg: float, sell_qty: int, pre_avg: float) -> None:
"""row의 pl_amt/prft_rt를 '전일 보유 평단(pre_avg)' 기준으로 덮어쓴다 (in-place).
그로스 = (매도평단 - 전일평단) × 매도수량, pl_amt = 그로스 - 수수료·세금 (ka10170과 동일 net).
"""
gross = (sell_avg - pre_avg) * sell_qty
fee = int(row.get('cmsn_tax') or 0)
row['pl_amt'] = round(gross) - fee
cost = pre_avg * sell_qty
row['prft_rt'] = round(row['pl_amt'] / cost * 100, 2) if cost else 0.0
row['_pl_rebased'] = True
row['prev_avg'] = round(pre_avg)
def rebase_pl(rows: list[dict]) -> list[dict]:
"""매도 실현손익을 '전일 보유 평단' 기준으로 재계산해 **새 리스트** 반환 (파일 미변경, 표시 전용).
ka10170은 당일 매수+매도가 같이 있으면 당일 매수평단↔매도평단 round-trip으로 pl_amt를 매겨
어제까지 보유한 원물의 손익을 가린다 (2026-06-29 0193W0: 6/26 보유 62@24,550 +
당일 62@21,875 매수 후 62@21,885 매도 → ka10170 +220원, 실제 원물 손익 약 -165,630원).
(account, code)별로 날짜순 재생(_advance)하며, **당일 매수가 동반된 매도 행**만
당일 매수 전 보유 평단을 원가로 잡아 덮어쓴다. 순수 매도(당일 매수 없음)는 키움 값이
정확하므로 건드리지 않는다.
⚠️ 입력 rows는 각 (account, code)의 **전체 이력**을 포함해야 재생이 정확하다.
"""
out = [dict(r) for r in rows]
by_key: dict[tuple, list[dict]] = {}
for r in out:
by_key.setdefault((r.get('account'), r.get('code')), []).append(r)
for grp in by_key.values():
grp.sort(key=lambda r: (r.get('date', ''), r.get('collected_at', '')))
qty, avg = 0, 0.0
for r in grp:
bq = int(r.get('buy_qty') or 0)
sq = int(r.get('sell_qty') or 0)
ba = float(r.get('buy_avg') or 0)
sa = float(r.get('sell_avg') or 0)
pre_avg = avg # 당일 매수 반영 전 = 전일 보유 평단
if sq > 0 and bq > 0 and pre_avg > 0:
_apply_rebase(r, sa, sq, pre_avg)
qty, avg = _advance(qty, avg, bq, sq, ba)
return out
def prev_avg_map(before_iso: str) -> dict:
"""파일 이력 중 before_iso **미만**(전일까지)을 (account, code)별 재생해 시작 평단 맵 반환.
라이브 journal(당일분)의 round-trip 오염 행을 재계산할 때 원가(전일 보유 평단) 소스.
{(account, code): avg}. 보유 0이면 0.
"""
rows = [r for r in _load_all() if r.get('date', '') < before_iso]
by_key: dict[tuple, list[dict]] = {}
for r in rows:
by_key.setdefault((r.get('account'), r.get('code')), []).append(r)
out: dict[tuple, float] = {}
for key, grp in by_key.items():
grp.sort(key=lambda r: (r.get('date', ''), r.get('collected_at', '')))
qty, avg = 0, 0.0
for r in grp:
qty, avg = _advance(
qty, avg,
int(r.get('buy_qty') or 0), int(r.get('sell_qty') or 0),
float(r.get('buy_avg') or 0),
)
out[key] = avg
return out
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())