auto: 일일 백업 2026-06-30 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user