"""스캔 엔진 — 15분마다 universe 를 훑어 가상 매수/매도하고 판단 스냅샷을 남긴다.

흐름: universe → 배치 시세 → (보유=청산판단 / 미보유=추세스크린→수급·애널→매수판단)
     → 가상 체결 → portfolio 저장 → last_scan.json (대시보드용) 기록.
"""
from __future__ import annotations

import json
import sys
import time
from datetime import datetime, timedelta

from . import benchmark, config, data, signals, universe
from .portfolio import Portfolio


def _held_days(position, now) -> int | None:
    """보유 경과 일수(달력) — entry_at 기준. 시간손절 판단용. 파싱 실패 시 None."""
    ts = position.get('entry_at')
    if not ts:
        return None
    try:
        return max(0, (now - datetime.fromisoformat(ts)).days)
    except Exception:
        return None


def _index_trend_ok() -> dict:
    """지수별 추세 — 지수 종가가 자기 SMA_LONG 위면 True. 데이터 부족 시 True(비차단)."""
    out = {}
    for mk in ('KOSPI', 'KOSDAQ'):
        store = benchmark.series(mk)
        if not store:
            continue
        closes = [v for _, v in sorted(store.items())]
        if len(closes) < config.SMA_LONG + 1:
            out[mk] = True
            continue
        sma = sum(closes[-config.SMA_LONG:]) / config.SMA_LONG
        out[mk] = closes[-1] >= sma
    return out


def _session_elapsed_frac(now) -> float:
    """장 시작 후 경과 비율 (0.15~1.0) — 장중 부분 거래량을 하루치로 환산하는 분모.
    장외면 1.0(완성봉 그대로). 하한 0.15는 장 초반 과대 환산 폭주 방지."""
    if not is_market_session(now):
        return 1.0
    o = now.replace(hour=config.MARKET_OPEN[0], minute=config.MARKET_OPEN[1], second=0, microsecond=0)
    c = now.replace(hour=config.MARKET_CLOSE[0], minute=config.MARKET_CLOSE[1], second=0, microsecond=0)
    total = (c - o).total_seconds()
    return min(1.0, max(0.15, (now - o).total_seconds() / total))


def _watching(snap) -> list:
    """스캔 시점 '매수로 노리는' 종목 — 즉시매수(BUY)·추격(ADD)·눌림목 대기(WAIT+지정가). 비교탭 표시용."""
    out = []
    for d in snap.get('decisions', []):
        st = d.get('state')
        if st in ('BUY', 'ADD') or (st == 'WAIT' and d.get('watch_price')):
            out.append({'name': d.get('name'), 'code': d.get('code'), 'state': st,
                        'reason': d.get('reason', ''), 'price': d.get('price'),
                        'watch_price': d.get('watch_price')})
    return out


def _index_return(mk: str, days: int):
    """지수 N거래일 수익률(%) — 상대강도 비교용. 데이터 부족 시 None."""
    s = sorted(benchmark.series(mk).items())
    if len(s) > days and s[-(days + 1)][1]:
        return (s[-1][1] / s[-(days + 1)][1] - 1) * 100
    return None


def _index_daily_change() -> dict:
    """지수별 당일 등락률(%) — 전일 종가 대비. 데이터 부족 시 키 없음."""
    out = {}
    for mk in ('KOSPI', 'KOSDAQ'):
        s = sorted(benchmark.series(mk).items())
        if len(s) >= 2 and s[-2][1]:
            out[mk] = (s[-1][1] / s[-2][1] - 1) * 100
    return out


# 시장 국면 6단계 — (이모지, 라벨, 매수허용, 설명, 색상). market_ok·시장탭 배지 공용.
REGIME = {
    'strong':  ('🔥', '강세', True, '상승 종목 많고 지수도 20일선 위 — 추세 양호', '#16a34a'),
    'rebound': ('📈', '반등', True, '상승 종목 많지만 지수는 아직 20일선 아래 — 약세 탈출 시도', '#22c55e'),
    'mild':    ('🌤', '완만 상승', True, '상승이 우위지만 강하진 않음', '#65a30d'),
    'mixed':   ('➖', '혼조', False, '상승·하락 비슷 — 방향 불명, 신규매수 보류', '#6b7280'),
    'weak':    ('📉', '약세', False, '하락 종목 우위 — 신규매수 보류', '#ef4444'),
    'crash':   ('❄️', '급락', False, '대부분 하락 + 지수 급락 — 신규매수 보류', '#b91c1c'),
}


def market_regime_map() -> dict:
    """시장별 국면(6단계) — 상승비율(폭) + 지수추세(방향) + 당일등락(강도) 조합.
    상승비율 항상 최신(네이버, 실패 시 EOD). market_ok·시장탭 배지가 공용으로 사용."""
    breadth = _live_breadth()
    if breadth is None:
        breadth = _eod_breadth()
    chg = _index_daily_change()
    trend = _index_trend_ok()
    out = {}
    for mk, v in breadth.items():
        c = chg.get(mk)
        up = trend.get(mk, True)
        if v < config.MARKET_BREADTH_MIN:                  # <15%
            out[mk] = 'crash' if (c is not None and c <= -2.0) else 'weak'
        elif v >= config.MARKET_BREADTH_STRONG:            # ≥60%
            out[mk] = 'strong' if up else 'rebound'
        elif v >= 0.40:
            out[mk] = 'mild'
        elif v >= 0.25:
            out[mk] = 'mixed'
        else:
            out[mk] = 'weak'
    return out


def _load_jsonl_last_per_market(path) -> dict[str, dict]:
    out: dict[str, dict] = {}
    if not path.exists():
        return out
    try:
        for line in path.read_text().splitlines():
            if not line.strip():
                continue
            rec = json.loads(line)
            out[rec.get('market', '')] = rec
    except Exception:
        pass
    return out


_LIVE_BREADTH_CACHE: dict[str, float] | None = None
_LIVE_BREADTH_AT: float = 0.0
_LIVE_BREADTH_TTL = 60.0


def _live_breadth() -> dict[str, float] | None:
    """장중 실시간 상승비율(rise/total) — 네이버 m.stock 1콜/시장. 60s 캐시, 실패 시 None."""
    global _LIVE_BREADTH_CACHE, _LIVE_BREADTH_AT
    now = time.time()
    if _LIVE_BREADTH_CACHE is not None and (now - _LIVE_BREADTH_AT) < _LIVE_BREADTH_TTL:
        return _LIVE_BREADTH_CACHE
    try:
        sys.path.insert(0, str(config.SCRIPTS))
        import market_indicators_sync as mis
        out: dict[str, float] = {}
        for sym, lab in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')):
            r = mis.fetch_market(sym, lab)
            total = (r.get('rise', 0) + r.get('fall', 0) + r.get('steady', 0)) if r else 0
            if not total:
                return None
            out[sym] = r['rise'] / total
        _LIVE_BREADTH_CACHE, _LIVE_BREADTH_AT = out, now
        return out
    except Exception:
        return None


def _eod_breadth() -> dict[str, float]:
    """장외 폴백 — 마지막 EOD 누적 상승비율."""
    latest = _load_jsonl_last_per_market(config.MARKET_HISTORY)
    out: dict[str, float] = {}
    for label, rec in latest.items():
        total = rec.get('rise', 0) + rec.get('fall', 0) + rec.get('steady', 0)
        breadth = rec.get('rise', 0) / total if total else 1.0
        key = 'KOSPI' if '코스피' in (rec.get('market_label') or '') or label == 'KOSPI' else \
              'KOSDAQ' if '코스닥' in (rec.get('market_label') or '') or label == 'KOSDAQ' else label
        out[key] = breadth
    return out


def market_ok_map() -> dict[str, bool]:
    """시장별 신규매수 허용 여부 — 6단계 국면(market_regime_map) 기반.
    강세·반등·완만상승 = 매수 허용 / 혼조·약세·급락 = 보류."""
    return {mk: REGIME[g][2] for mk, g in market_regime_map().items()}


def _is_holiday(d: datetime) -> bool:
    try:
        hol = json.loads(config.HOLIDAYS_PATH.read_text())
        days = hol if isinstance(hol, list) else hol.get('holidays', [])
        return d.strftime('%Y-%m-%d') in set(days) or d.strftime('%Y%m%d') in set(days)
    except Exception:
        return False


def is_market_session(now: datetime | None = None) -> bool:
    now = now or datetime.now(config.KST)
    if now.weekday() >= 5 or _is_holiday(now):
        return False
    o = now.replace(hour=config.MARKET_OPEN[0], minute=config.MARKET_OPEN[1], second=0, microsecond=0)
    c = now.replace(hour=config.MARKET_CLOSE[0], minute=config.MARKET_CLOSE[1], second=0, microsecond=0)
    return o <= now <= c


def _fill_buy_price(code: str, suggested: int) -> int:
    book = data.quote_book(code)
    if book and book.get('asks'):
        ask1 = book['asks'][0].get('price')
        if ask1:
            return int(ask1)
    return int(suggested)


def _fill_sell_price(code: str, fallback: int) -> int:
    book = data.quote_book(code)
    if book and book.get('bids'):
        bid1 = book['bids'][0].get('price')
        if bid1:
            return int(bid1)
    return int(fallback)


_SCAN_CANDLES: dict = {}   # 스캔 1회 동안 종목별 일봉 공유
_IND_MEMO: dict = {}       # (code, 지표 파라미터) → 지표 묶음 — 같은 지표 설정 변이끼리 공유


def _ind_key(code):
    return (code, config.SMA_SHORT, config.SMA_LONG, config.SMA_MID, config.ATR_PERIOD,
            config.BREAKOUT_LOOKBACK, config.SWING_LOW_LOOKBACK, config.VOLUME_AVG_PERIOD,
            config.RSI_PERIOD, config.TREND_SLOPE_DAYS)


LAST_QUOTES_PATH = config.STATE_DIR / 'last_quotes.json'


def _save_last_quotes(quotes: dict) -> None:
    """직전 스캔이 모은 시세 — judge_view(관심종목 탭)가 라이브 재호출 없이 같은 값을 재사용."""
    if not quotes:   # 시세 수집 실패(빈 dict)면 직전 정상값 보존 — 덮어쓰지 않음
        return
    try:
        config.STATE_DIR.mkdir(parents=True, exist_ok=True)
        LAST_QUOTES_PATH.write_text(json.dumps(quotes, ensure_ascii=False))
    except Exception as e:
        sys.stderr.write(f'[engine] last_quotes 저장 실패: {e}\n')


def _load_last_quotes() -> dict | None:
    """직전 스캔 시세. 없으면 None → 호출부가 라이브 폴백."""
    try:
        return json.loads(LAST_QUOTES_PATH.read_text())
    except Exception:
        return None


def _gather(extra_codes=None, quotes=None):
    """파라미터 무관한 라이브 데이터 1회 수집 (메인·변이 공유). 종목·시세·시장매핑.

    extra_codes: universe에 없지만 청산 판단을 위해 포함해야 할 보유 코드(수동 삭제된 보유분).
    quotes: 주어지면 라이브 시세 호출을 건너뛰고 그대로 사용 (judge_view 가 직전 스캔 시세 재사용).
    """
    data.reset_book_memo()   # 호가 공유 캐시 리셋 — 이번 스캔의 체결가를 전 선수가 공유
    _SCAN_CANDLES.clear()
    _IND_MEMO.clear()
    uni = universe.build_universe()
    codes = [e['code'] for e in uni]
    seen = set(codes)
    for c in (extra_codes or set()):
        if c and c not in seen:
            uni = uni + [{'code': c, 'name': '', 'sources': ['held']}]
            codes.append(c)
            seen.add(c)
    if not quotes:   # None(미지정) 또는 빈 dict(저장본 비었음)면 라이브 수집
        quotes = data.batch_quotes(codes)
    cmkt = universe.code_market_map()
    return uni, quotes, cmkt


def _run_scan(pf, uni, quotes, cmkt, now, execute: bool = True) -> dict:
    """주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X).

    execute=False 면 판단(decisions)만 만들고 체결·계좌 저장을 모두 생략 — 시각 전환 뷰 등 read-only 용."""
    mkt_ok = market_ok_map()   # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산
    idx_ret = {mk: _index_return(mk, config.REL_STRENGTH_DAYS) for mk in ('KOSPI', 'KOSDAQ')}
    vfrac = _session_elapsed_frac(now)   # 장중 부분 거래량 → 하루치 환산 분모
    decisions: list[dict] = []
    buys: list[dict] = []
    sells: list[dict] = []

    for e in uni:
        code, name, sources = e['code'], e['name'], e['sources']
        if not name and code in pf.positions:  # 보유 보강분(universe에서 빠진)은 이름 보완
            name = pf.positions[code].get('name', code)
        quote = quotes.get(code)
        hist = _SCAN_CANDLES.get(code)
        if hist is None:
            hist = data.candles(code, 80)
            _SCAN_CANDLES[code] = hist
        series = data.build_series(hist, quote)
        _ik = _ind_key(code)
        _cached = _IND_MEMO.get(_ik)
        if _cached is None:
            _cached = signals.compute_indicators(series) or False
            _IND_MEMO[_ik] = _cached
        ind = dict(_cached) if _cached else None   # 사본 — 변이별 주입값(rel_strength 등) 오염 방지
        if ind is None:
            decisions.append({'code': code, 'name': name, 'sources': sources,
                              'state': 'NODATA', 'reason': '데이터 부족', 'checks': [],
                              'price': (quote or {}).get('price')})
            continue
        cur_price = ind['price']

        # ---- 보유: 청산 판단 ----
        if code in pf.positions:
            pf.mark(code, cur_price)
            flow = data.flow_net(data.investor_flow(code))
            anl = data.analyst(code, cur_price)
            dec = signals.evaluate_holding(pf.positions[code], ind, flow, anl,
                                           held_days=_held_days(pf.positions[code], now),
                                           market_ok=mkt_ok.get(cmkt.get(code, ''), True))
            pf.apply_position_update(code, dec['position_update'])
            dec.update({k: pf.positions[code].get(k) for k in
                        ('entry_price', 'qty', 'stop', 'target', 'trailing_on', 'entry_at')})
            act = dec['action']
            if execute and act in ('sell', 'scale_out'):
                fill = _fill_sell_price(code, cur_price)
                rec = pf.sell(code, fill, dec['reason'], signals=dec.get('checks'),
                              frac=dec.get('sell_frac', 1.0))
                if rec:
                    sells.append(rec)
                    dec['fill_price'] = fill
            elif execute and act == 'add':
                fill = _fill_buy_price(code, cur_price)
                rec = pf.add_tranche(code, fill, dec['reason'], signals=dec.get('checks'))
                if rec:
                    # 새 평단 기준 손절·목표 재산정 (손절은 위로만 래칫)
                    new_entry = pf.positions[code]['entry_price']
                    nstop, ntarget = signals.compute_stop_target(new_entry, ind['atr'], ind['recent_low'])
                    # 추격매수(up)=손절 위로만 래칫 / 물타기(down)=낮아진 평단 기준으로 손절 재설정
                    pf.positions[code]['stop'] = (nstop if dec.get('add_kind') == 'down'
                                                  else max(pf.positions[code]['stop'], nstop))
                    pf.positions[code]['target'] = ntarget
                    buys.append(rec)
                    dec.update({'fill_price': fill, 'entry_price': new_entry,
                                'qty': pf.positions[code]['qty'], 'stop': pf.positions[code]['stop'],
                                'target': ntarget})
            decisions.append(dec)
            continue

        # ---- 미보유: 추세 스크린 → 수급·애널 → 매수 판단 ----
        if universe.is_etf(name):
            decisions.append({
                'code': code, 'name': name, 'sources': sources, 'price': cur_price,
                'state': 'SKIP', 'reason': 'ETF — 자동매매 제외(방향성 애널 부재)', 'action': None,
                'checks': [],
            })
            continue
        if not signals.trend_ok(ind):
            decisions.append({
                'code': code, 'name': name, 'sources': sources, 'price': cur_price,
                'state': 'SKIP', 'reason': '추세 미충족', 'action': None,
                'checks': signals.trend_checks(ind),   # 4조건+상대강도 모두 표시 (왜 제외인지 보이게)
            })
            continue

        flow = data.flow_net(data.investor_flow(code))
        anl = data.analyst(code, cur_price)
        market = cmkt.get(code, '')
        m_ok = mkt_ok.get(market, True)
        ind['vol_time_frac'] = vfrac
        # 상대강도(라이브 전용): 종목 N일 수익률 − 시장지수 N일 수익률
        n = config.REL_STRENGTH_DAYS
        closes_c = [c['close'] for c in series]
        if len(closes_c) > n and closes_c[-(n + 1)] and idx_ret.get(market) is not None:
            stock_ret = (closes_c[-1] / closes_c[-(n + 1)] - 1) * 100
            ind['rel_strength'] = round(stock_ret - idx_ret[market], 2)
        dec = signals.evaluate_candidate(code, name, sources, ind, flow, anl, m_ok)

        if dec['action'] == 'buy' and pf.exited_today(code):
            # 당일 전량청산 종목 재매수 금지 — 휩쏘 churn(손절→5분 뒤 재진입 반복) 방지
            dec.update({'state': 'WAIT', 'action': None,
                        'reason': '당일 청산 종목 — 재진입 쿨다운(내일부터 가능)'})
        if execute and dec['action'] == 'buy' and pf.can_open():
            fill = _fill_buy_price(code, dec['buy_price'])
            stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low'])
            rec = pf.buy(code, name, fill, sources, stop, target, dec['buy_path'], dec['reason'],
                         signals=dec.get('checks'))
            if rec:
                buys.append(rec)
                dec.update({'fill_price': fill, 'plan_stop': stop, 'plan_target': target})
            else:
                dec['state'] = 'WAIT'
                dec['reason'] = '매수 신호 — 현금/한도 부족'
        decisions.append(dec)

    if execute:
        pf.update_equity_metrics()
        pf.save()

    order = {'SELL': 0, 'ADD': 1, 'BUY': 2, 'HOLD': 3, 'WAIT': 4, 'SKIP': 5, 'NODATA': 6}
    decisions.sort(key=lambda d: (order.get(d.get('state'), 9), d.get('name', '')))

    return {
        'scanned_at': now.isoformat(),
        'next_scan_hint': (now + timedelta(minutes=15)).isoformat(),
        'session': is_market_session(now),
        'market_ok': mkt_ok,
        'summary': pf.summary(),
        'counts': {'buys': len(buys), 'sells': len(sells), 'universe': len(uni)},
        'decisions': decisions,
    }


def judge_view(vid: str) -> dict | None:
    """선택한 계좌('main'/'vN') 시각으로 **매매 없이 판단만** — 관심종목 탭 시각 전환용.

    execute=False 라 체결·계좌 저장이 전혀 없다. 끝나면 config 를 메인 파라미터로 복원."""
    from . import backtest, variants as variants_mod
    now = datetime.now(config.KST)
    if vid in ('main', 'M', ''):
        params = config.load_params()
        pf = Portfolio.load()
    else:
        v = next((x for x in variants_mod.load_variants() if x['id'] == vid), None)
        if not v:
            return None
        pp, tp = variants_mod.variant_paths(vid)
        params = v.get('params') or {}
        pf = Portfolio.load(pp, tp)
    try:
        backtest.apply_params(params)
        # 직전 스캔이 저장한 시세 재사용 — 라이브 재호출 없이 보유종목 가격과 같은 시점으로 통일.
        # 스캔 이력이 아직 없으면(None) _gather 가 라이브 폴백.
        uni, quotes, cmkt = _gather(set(pf.positions), quotes=_load_last_quotes())
        return _run_scan(pf, uni, quotes, cmkt, now, execute=False)
    finally:
        backtest.apply_params(config.load_params())   # 전역 config 원복 (웹 데몬 잔류 방지)


def _attach_benchmark(snap: dict, pf, now, refresh: bool = True):
    """가상계좌 시작일 대비 기준지수(KOSPI/KOSDAQ) 수익·알파를 스냅샷에 부착."""
    from . import benchmark
    if refresh:
        benchmark.update_today()
    start = benchmark.norm_date(pf.created_at)
    today = now.strftime('%Y%m%d')
    snap['benchmark'] = {
        'since': start, 'to': today,
        'indices': benchmark.compare(snap['summary'].get('total_return_pct'), start, today),
    }


def scan(force: bool = False) -> dict:
    """메인 sim 1회 스캔 — last_scan.json 기록."""
    now = datetime.now(config.KST)
    if not force and not is_market_session(now):
        return {'skipped': True, 'reason': '장외/휴장', 'at': now.isoformat()}
    pf = Portfolio.load()
    uni, quotes, cmkt = _gather(set(pf.positions))  # 보유분은 universe에서 빠져도 청산 위해 포함
    _save_last_quotes(quotes)
    snap = _run_scan(pf, uni, quotes, cmkt, now)
    _attach_benchmark(snap, pf, now)
    config.STATE_DIR.mkdir(parents=True, exist_ok=True)
    config.LAST_SCAN_PATH.write_text(json.dumps(snap, ensure_ascii=False, indent=2))
    return snap


def scan_all(force: bool = False) -> dict:
    """메인 + 병렬 페이퍼 변이 전부 스캔 (라이브 데이터 1회 공유). 비교 스냅샷 기록."""
    from . import backtest, variants as variants_mod
    now = datetime.now(config.KST)
    if not force and not is_market_session(now):
        return {'skipped': True, 'reason': '장외/휴장', 'at': now.isoformat()}

    # 포트폴리오 먼저 로드해 보유 코드 합집합 수집 (universe에서 빠진 보유분도 청산 위해 포함)
    main_params = config.load_params()
    pf = Portfolio.load()
    vdefs = variants_mod.load_variants()
    vpfs = {v['id']: Portfolio.load(*variants_mod.variant_paths(v['id'])) for v in vdefs}
    held_union = set(pf.positions)
    for vpf in vpfs.values():
        held_union |= set(vpf.positions)
    uni, quotes, cmkt = _gather(held_union)
    _save_last_quotes(quotes)

    # 기준 판단 (params.json) — 2026-06-10 기준전략 폐지: 매매 없이 판단만(last_scan = 화면·상태점용).
    # 실제 매매는 전부 변이들이 한다 (메인 계좌 동결).
    backtest.apply_params(main_params)
    snap = _run_scan(pf, uni, quotes, cmkt, now, execute=False)
    _attach_benchmark(snap, pf, now)            # 지수 캐시 갱신 1회 (변이는 캐시 재사용)
    config.STATE_DIR.mkdir(parents=True, exist_ok=True)
    config.LAST_SCAN_PATH.write_text(json.dumps(snap, ensure_ascii=False, indent=2))

    from . import benchmark
    today = now.strftime('%Y%m%d')

    def _alpha(p):
        s = p.summary()
        return benchmark.compare(s.get('total_return_pct'), benchmark.norm_date(p.created_at), today)

    compare = []   # 기준전략 폐지 — 변이들만 비교 (메인 계좌는 매매 안 함)
    for v in vdefs:
        backtest.apply_params(v.get('params') or {})
        vpf = vpfs[v['id']]
        vsnap = _run_scan(vpf, uni, quotes, cmkt, now)
        compare.append({'id': v['id'], 'name': v.get('name', v['id']),
                        'params': v.get('params') or {}, 'summary': vpf.summary(),
                        'benchmark': _alpha(vpf), 'watching': _watching(vsnap),
                        'held': sorted(vpf.positions)})

    compare.sort(key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True)

    # 자동편입(auto) 종목 신호 추적 — 한 변이라도 노리는(매수/대기) 종목은 '살아있음' 기록 (auto_prune 판단용)
    try:
        from . import universe as _um
        sig = {it['code'] for c in compare for it in (c.get('watching') or []) if it.get('code')}
        if sig:
            _um.mark_signals(sorted(sig))
    except Exception:
        pass

    variants_mod.COMPARE_PATH.write_text(json.dumps({
        'scanned_at': now.isoformat(), 'session': is_market_session(now),
        'variants': compare,
    }, ensure_ascii=False, indent=2))
    _record_equity_history(compare, now)
    return snap


EQUITY_HISTORY_PATH = config.STATE_DIR / 'equity_history.json'


def _record_equity_history(compare: list, now) -> None:
    """일별 계좌 성과 이력 — {date: [{id, ph(파라미터 해시), equity, ret}]}.
    매 스캔 당일 항목을 덮어써 그날 마지막 스캔(≈장 마감) 값이 남는다. 변이 재구성으로
    id가 재사용돼도 ph로 어느 전략이었는지 식별 가능. 사후 국면별 분석용 누적."""
    import hashlib
    try:
        hist = json.loads(EQUITY_HISTORY_PATH.read_text()) if EQUITY_HISTORY_PATH.exists() else {}
    except Exception:
        hist = {}
    today = now.strftime('%Y%m%d')
    hist[today] = [{
        'id': c['id'],
        'ph': hashlib.md5(json.dumps(c.get('params') or {}, sort_keys=True).encode()).hexdigest()[:8],
        'equity': c['summary'].get('equity'),
        'ret': c['summary'].get('total_return_pct'),
    } for c in compare]
    try:
        EQUITY_HISTORY_PATH.write_text(json.dumps(hist, ensure_ascii=False))
    except Exception as e:
        sys.stderr.write(f'[engine] equity_history 기록 실패: {e}\n')


if __name__ == '__main__':
    force = '--force' in sys.argv
    snap = scan_all(force=force)
    if snap.get('skipped'):
        print(f"[skip] {snap['reason']} @ {snap['at']}")
    else:
        s = snap['summary']
        print(f"자산 {s['equity']:,}원 ({s['total_return_pct']:+.2f}%) · "
              f"현금 {s['cash']:,} · 보유 {s['open_positions']} · "
              f"매수 {snap['counts']['buys']} 매도 {snap['counts']['sells']}")
        for d in snap['decisions']:
            if d['state'] in ('BUY', 'ADD', 'SELL', 'HOLD'):
                print(f"  [{d['state']}] {d['name']:12s} {d.get('reason','')}")
