auto: 일일 백업 2026-07-01 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,7 @@ sys.path.insert(0, str(WORKSPACE / 'scripts'))
|
||||
|
||||
_holidays_cache: dict = {'mtime': 0.0, 'set': set()}
|
||||
_traded_codes_cache: dict = {'mtime': 0.0, 'set': set()}
|
||||
_holding_since_cache: dict = {'mtime': None, 'map': {}}
|
||||
_quote_cache: dict[str, tuple[dict, float]] = {}
|
||||
_quote_cache_lock = threading.Lock()
|
||||
|
||||
@@ -443,6 +444,55 @@ def _load_traded_codes() -> set[str]:
|
||||
return _traded_codes_cache.get('set', set())
|
||||
|
||||
|
||||
def _holding_since_map() -> dict:
|
||||
"""trade_journal.jsonl 재구성 → {code: 'YYYYMMDD'} 현재 연속 보유 시작일.
|
||||
보유종목 '매수후 고점대비' 낙폭 계산용. mtime 기반 lazy reload.
|
||||
계좌·owner 무관 code 단위 순매수 누적 — 보유수량이 0으로 떨어지면 streak 리셋,
|
||||
재진입 시 마지막 streak 시작일. (드물게 본인·가희 동시보유 시 더 이른 날로 근사)."""
|
||||
if not TRADE_JOURNAL_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
mtime = TRADE_JOURNAL_FILE.stat().st_mtime
|
||||
except OSError:
|
||||
return _holding_since_cache.get('map', {})
|
||||
if mtime != _holding_since_cache.get('mtime'):
|
||||
try:
|
||||
events: dict[str, list[tuple[str, int]]] = {}
|
||||
for ln in TRADE_JOURNAL_FILE.read_text().splitlines():
|
||||
ln = ln.strip()
|
||||
if not ln:
|
||||
continue
|
||||
try:
|
||||
d = json.loads(ln)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
c = d.get('code')
|
||||
if not c:
|
||||
continue
|
||||
day = (d.get('date') or '').replace('-', '')
|
||||
net = int(d.get('buy_qty') or 0) - int(d.get('sell_qty') or 0)
|
||||
events.setdefault(c, []).append((day, net))
|
||||
out: dict[str, str] = {}
|
||||
for c, evs in events.items():
|
||||
evs.sort(key=lambda x: x[0])
|
||||
run = 0
|
||||
start = None
|
||||
for day, net in evs:
|
||||
if run <= 0 and net > 0:
|
||||
start = day
|
||||
run += net
|
||||
if run <= 0:
|
||||
run = 0
|
||||
start = None
|
||||
if run > 0 and start:
|
||||
out[c] = start
|
||||
_holding_since_cache['map'] = out
|
||||
_holding_since_cache['mtime'] = mtime
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'holding_since load failed: {e}\n')
|
||||
return _holding_since_cache.get('map', {})
|
||||
|
||||
|
||||
_US_EASTERN = ZoneInfo('America/New_York')
|
||||
|
||||
|
||||
@@ -2150,6 +2200,13 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
f'<button type="button" class="btn-order" data-order-code="{html.escape(raw_code, quote=True)}" data-order-stock="{stock_attr}" data-order-side="{order_side}" title="매수/매도">💰 거래</button>'
|
||||
) if raw_code else ''
|
||||
|
||||
# +태그 버튼 — held 행과 동일하게 펼침 상세 종목명 줄 오른쪽에 둔다 (actions 아님).
|
||||
tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
detail_name_html = ''
|
||||
if not is_pending:
|
||||
code_span = f'<span class="detail-code">{code}</span>' if code else ''
|
||||
detail_name_html = f'<div class="detail-name">{stock}{code_span}{tag_add_btn}</div>'
|
||||
|
||||
if source == 'interests':
|
||||
def _num_attr(field, sub):
|
||||
if not isinstance(field, dict):
|
||||
@@ -2175,12 +2232,10 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
f' data-edit-stop="{html.escape(stop_attr_v, quote=True)}"'
|
||||
f' data-edit-memo="{html.escape(memo_val, quote=True)}"'
|
||||
)
|
||||
tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
note_btn = _note_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
actions_html = (
|
||||
'<div class="actions">'
|
||||
f'{tag_add_btn}'
|
||||
f'{note_btn}'
|
||||
f'{analyze_btn}'
|
||||
f'{info_btn}'
|
||||
@@ -2199,12 +2254,10 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
)
|
||||
cls = f'{cls} trash'
|
||||
else:
|
||||
wl_tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
wl_note_btn = _note_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
wl_info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
actions_html = (
|
||||
'<div class="actions">'
|
||||
f'{wl_tag_add_btn}'
|
||||
f'{wl_note_btn}'
|
||||
f'{analyze_btn}'
|
||||
f'{wl_info_btn}'
|
||||
@@ -2229,6 +2282,7 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
</div>
|
||||
</summary>
|
||||
<div class="detail">
|
||||
{detail_name_html}
|
||||
{'<div class="alert-notice">최초 알림 완료 — 리셋 전까지 매수 알림 없음</div>' if unheld_done else ''}
|
||||
<div class="grid2">
|
||||
{held_row}
|
||||
@@ -2878,6 +2932,38 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
]
|
||||
dl_inner = _interleave_kv_pairs(left_pairs, right_pairs)
|
||||
pl_pairs.extend(journal_lines)
|
||||
# 매수후 고점대비 — 보유 시작일 이후 일봉 고가 최댓값 대비 현재가 낙폭(%·평가액).
|
||||
# 매수/매도(journal_lines) 아래에 표시. 순수 캐시 읽기(_select_latest)만 — 렌더 중 키움 호출 방지.
|
||||
since = _holding_since_map().get(r.get('code') or '')
|
||||
peak_high = 0
|
||||
if since and code:
|
||||
try:
|
||||
import daily_candles_cache as dcc
|
||||
for cd in dcc._select_latest(code, 250):
|
||||
d = str(cd.get('date') or '')
|
||||
h = cd.get('high') or 0
|
||||
if d >= since and h > peak_high:
|
||||
peak_high = h
|
||||
except Exception:
|
||||
peak_high = 0
|
||||
oh_today = r.get('ohlc') or {}
|
||||
if oh_today.get('h'):
|
||||
peak_high = max(peak_high, oh_today['h'])
|
||||
if price:
|
||||
peak_high = max(peak_high, price)
|
||||
if peak_high > 0 and price > 0:
|
||||
dd_pct = (price - peak_high) / peak_high * 100
|
||||
dd_won = (price - peak_high) * qty
|
||||
ddcls = _profit_class(dd_won)
|
||||
pl_pairs.append('<div class="pl-divider"></div>')
|
||||
pl_pairs.append(
|
||||
f'<dt>매수후 고점</dt><dd class="num">{peak_high:,}원 '
|
||||
f'<span class="{ddcls}">({dd_pct:.2f}%)</span></dd>'
|
||||
)
|
||||
pl_pairs.append(
|
||||
f'<dt>고점대비</dt><dd class="num"><span class="muted small">{dd_won:,}원</span></dd>'
|
||||
)
|
||||
pl_pairs.append('<div class="pl-divider"></div>')
|
||||
pl_dl_inner = ''.join(pl_pairs)
|
||||
|
||||
row_key = (code or stock) + key_suffix
|
||||
@@ -2885,7 +2971,6 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
note_btn = _note_button_html(r.get('code') or '', r.get('stock') or '')
|
||||
trade_btn = (
|
||||
f'<div class="actions">'
|
||||
f'{tag_add_btn}'
|
||||
f'{note_btn}'
|
||||
f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}'
|
||||
f'<button type="button" class="btn-trades" data-trade-code="{code}" data-trade-stock="{stock}">거래내역</button>'
|
||||
@@ -2908,7 +2993,7 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
{candle_summary}
|
||||
</summary>
|
||||
<div class="detail">
|
||||
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}</div>
|
||||
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}{tag_add_btn}</div>
|
||||
{f'<div class="detail-tag">{tag_chip}</div>' if tag_chip else ''}
|
||||
<dl>{dl_inner}</dl>
|
||||
<dl class="pl-summary">{pl_dl_inner}</dl>
|
||||
@@ -3013,7 +3098,7 @@ def _render_pending_buy_held_row(r: dict) -> str:
|
||||
else:
|
||||
diff_html = ''
|
||||
|
||||
price_html = f'{price:,}' if price else '<span class="muted">-</span>'
|
||||
price_html = f'<span class="rt-px">{price:,}</span>' if price else '<span class="muted">-</span>'
|
||||
|
||||
summary_line2 = f'<span class="muted small">매수 대기 {buy_qty:,}주'
|
||||
if avg_buy:
|
||||
@@ -3085,7 +3170,7 @@ def _render_pending_sell_held_row(r: dict) -> str:
|
||||
else:
|
||||
diff_html = ''
|
||||
|
||||
price_html = f'{price:,}' if price else '<span class="muted">-</span>'
|
||||
price_html = f'<span class="rt-px">{price:,}</span>' if price else '<span class="muted">-</span>'
|
||||
|
||||
summary_line2 = f'<span class="muted small">매도 대기 {sell_qty:,}주'
|
||||
if avg_sell:
|
||||
@@ -3153,9 +3238,9 @@ def _render_pending_unheld_row(r: dict) -> str:
|
||||
if isinstance(day_pct, (int, float)):
|
||||
d_cls = 'day-pct up' if day_pct > 0 else ('day-pct down' if day_pct < 0 else 'day-pct neutral')
|
||||
d_sign = '+' if day_pct >= 0 else ''
|
||||
price_html = f'{price:,}<span class="{d_cls}">{d_sign}{day_pct:.2f}%</span>'
|
||||
price_html = f'<span class="rt-px">{price:,}</span><span class="{d_cls} rt-wl-daypct">{d_sign}{day_pct:.2f}%</span>'
|
||||
else:
|
||||
price_html = f'{price:,}'
|
||||
price_html = f'<span class="rt-px">{price:,}</span>'
|
||||
if avg_buy:
|
||||
diff = price - avg_buy
|
||||
dpct = (diff / avg_buy * 100) if avg_buy else 0.0
|
||||
@@ -4418,6 +4503,7 @@ details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-c
|
||||
/* 펼친 detail의 태그는 크게 (미리보기·관심행 칩은 작게 유지). */
|
||||
.detail-name { font-size: 15px; font-weight: 700; color: #f0f0f0; margin: 0 0 8px; display: flex; align-items: baseline; gap: 8px; }
|
||||
.detail-name .detail-code { font-size: 11px; font-weight: 500; color: #8b8f9a; font-variant-numeric: tabular-nums; }
|
||||
.detail-name .btn-add-tag { align-self: center; }
|
||||
.detail-tag { margin: 0 0 8px; }
|
||||
.detail-tag .tag-chip { font-size: 11px; padding: 2px 8px; border-radius: 4px; }
|
||||
.btn-add-tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #5a5f6c; background: transparent; border: 1px dashed rgba(120,124,135,0.30); white-space: nowrap; flex: none; cursor: pointer; font-family: inherit; }
|
||||
|
||||
Reference in New Issue
Block a user