auto: 일일 백업 2026-06-30 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-30 02:00:02 +09:00
parent 17ad101eb1
commit 1bbb61520c
154 changed files with 2564 additions and 662 deletions
+17 -1
View File
@@ -1202,6 +1202,18 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
tj.record_trades(journal_by_label, quiet=True, tag='render')
except Exception as e:
sys.stderr.write(f'[render→journal] record_trades failed: {e}\n')
# 당일 매수 동반 매도행의 pl을 '전일 보유 평단' 기준으로 재계산 (ka10170 round-trip 오표시 교정).
# 전일 평단은 파일 이력 재생에서. raw 저장(record_trades) 직후라 prev_avg_map은 당일분 제외(date<오늘).
try:
_today_iso = datetime.now(tj.KST).strftime('%Y-%m-%d')
_prev_avgs = tj.prev_avg_map(_today_iso)
for _lb, _trs in journal_by_label.items():
for _t in _trs:
_pa = _prev_avgs.get((_lb, _t.get('code')), 0)
if (_t.get('sell_qty') or 0) > 0 and (_t.get('buy_qty') or 0) > 0 and _pa > 0:
tj._apply_rebase(_t, float(_t.get('sell_avg') or 0), int(_t['sell_qty']), _pa)
except Exception as e:
sys.stderr.write(f'[journal rebase] failed: {e}\n')
cards = quotes_f.result()
try:
open_orders = open_orders_f.result()
@@ -8442,6 +8454,7 @@ def render_html() -> str:
'var pl=r.pl_amt||0;'
'var plCls=pl>0?"pl-pos":(pl<0?"pl-neg":"muted");'
'var sgn=pl>0?"+":"";'
'var reNote=r._pl_rebased?\'<div class="muted small" style="margin-top:2px">원가 \'+fmtInt(r.prev_avg)+\'원(전일 보유 평단) 기준 · 당일 매수분 제외</div>\':"";'
'out+=\'<li class="trade-item trade-item-sell\'+seedCls+\'">\'+stockLine+'
'\'<div class="ti-row1"><div class="ti-meta">\'+'
'\'<span class="ti-side side-sell">매도</span>\'+'
@@ -8450,7 +8463,7 @@ def render_html() -> str:
'\'<div class="ti-row2"><div class="ti-data">\'+'
'\'<span class="ti-price"><span class="ti-label">평균가</span> \'+fmtInt(r.sell_avg)+\'원</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-qty"><span class="ti-label">수량</span> \'+fmtInt(r.sell_qty)+\'주</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-amt"><span class="ti-label">금액</span> \'+fmtInt(r.sell_amt)+\'원</span></div></div>\'+'
'\'<span class="ti-amt"><span class="ti-label">금액</span> \'+fmtInt(r.sell_amt)+\'원</span></div></div>\'+reNote+'
'\'</li>\';'
'}'
'return out;'
@@ -10227,6 +10240,9 @@ class Handler(BaseHTTPRequestHandler):
rows = [r for r in all_rows if _row_owner(r) == owner_q]
if account:
rows = [r for r in rows if r.get('account') == account]
# 매도 실현손익을 '전일 보유 평단' 기준으로 재계산 (ka10170 round-trip 오표시 교정).
# rows는 해당 (account,code)의 전체 이력을 포함하므로 재생이 정확하다.
rows = tj.rebase_pl(rows)
rows.sort(key=lambda r: (r.get('date', ''), r.get('account', '')), reverse=True)
name = rows[0]['name'] if rows and code else ''
mode = 'owner' if (not code and owner_q) else 'code'
@@ -767,6 +767,20 @@ def build_report(by_account: bool = False) -> tuple[str, str, str]:
raise RuntimeError('키움 REST 응답에 보유종목이 없습니다.')
today_key = datetime.now(KST).strftime('%Y-%m-%d')
# 당일 매수 동반 매도행의 pl을 '전일 보유 평단' 기준으로 재계산 (ka10170 round-trip 오표시 교정).
# 자산웹과 동일 기준 — 전일 평단은 파일 이력 재생(prev_avg_map, 당일분 제외)에서.
try:
import trade_journal as tj
_prev_avgs = tj.prev_avg_map(today_key)
for _lb, _trs in journal_by_label.items():
for _t in _trs:
_pa = _prev_avgs.get((_lb, _t.get('code')), 0)
if (_t.get('sell_qty') or 0) > 0 and (_t.get('buy_qty') or 0) > 0 and _pa > 0:
tj._apply_rebase(_t, float(_t.get('sell_avg') or 0), int(_t['sell_qty']), _pa)
except Exception as e:
print(f'[journal rebase] failed: {e}', file=sys.stderr)
all_labels = [a['label'] for a in list_accounts()]
owner_groups = group_by_owner(all_labels)
@@ -67,6 +67,95 @@ def _save_all(rows: list[dict]) -> None:
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.