auto: 일일 백업 2026-06-11 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,29 @@ def _index_trend_ok() -> dict:
|
||||
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())
|
||||
@@ -192,11 +215,24 @@ def _fill_sell_price(code: str, fallback: int) -> int:
|
||||
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)
|
||||
|
||||
|
||||
def _gather(extra_codes=None):
|
||||
"""파라미터 무관한 라이브 데이터 1회 수집 (메인·변이 공유). 종목·시세·시장매핑.
|
||||
|
||||
extra_codes: universe에 없지만 청산 판단을 위해 포함해야 할 보유 코드(수동 삭제된 보유분).
|
||||
"""
|
||||
data.reset_book_memo() # 호가 공유 캐시 리셋 — 이번 스캔의 체결가를 전 선수가 공유
|
||||
_SCAN_CANDLES.clear()
|
||||
_IND_MEMO.clear()
|
||||
uni = universe.build_universe()
|
||||
codes = [e['code'] for e in uni]
|
||||
seen = set(codes)
|
||||
@@ -210,10 +246,13 @@ def _gather(extra_codes=None):
|
||||
return uni, quotes, cmkt
|
||||
|
||||
|
||||
def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
"""주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X)."""
|
||||
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] = []
|
||||
@@ -223,9 +262,17 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
if not name and code in pf.positions: # 보유 보강분(universe에서 빠진)은 이름 보완
|
||||
name = pf.positions[code].get('name', code)
|
||||
quote = quotes.get(code)
|
||||
hist = data.candles(code, 80)
|
||||
hist = _SCAN_CANDLES.get(code)
|
||||
if hist is None:
|
||||
hist = data.candles(code, 80)
|
||||
_SCAN_CANDLES[code] = hist
|
||||
series = data.build_series(hist, quote)
|
||||
ind = signals.compute_indicators(series)
|
||||
_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': [],
|
||||
@@ -244,14 +291,14 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
dec.update({k: pf.positions[code].get(k) for k in
|
||||
('entry_price', 'qty', 'stop', 'target', 'trailing_on', 'entry_at')})
|
||||
act = dec['action']
|
||||
if act in ('sell', 'scale_out'):
|
||||
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 act == 'add':
|
||||
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:
|
||||
@@ -292,6 +339,7 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
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]
|
||||
@@ -300,7 +348,11 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
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.can_open():
|
||||
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'],
|
||||
@@ -313,8 +365,9 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
dec['reason'] = '매수 신호 — 현금/한도 부족'
|
||||
decisions.append(dec)
|
||||
|
||||
pf.update_equity_metrics()
|
||||
pf.save()
|
||||
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', '')))
|
||||
@@ -330,6 +383,30 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
uni, quotes, cmkt = _gather(set(pf.positions))
|
||||
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
|
||||
@@ -374,9 +451,10 @@ def scan_all(force: bool = False) -> dict:
|
||||
held_union |= set(vpf.positions)
|
||||
uni, quotes, cmkt = _gather(held_union)
|
||||
|
||||
# 메인 (현재 params.json 튜닝)
|
||||
# 기준 판단 (params.json) — 2026-06-10 기준전략 폐지: 매매 없이 판단만(last_scan = 화면·상태점용).
|
||||
# 실제 매매는 전부 변이들이 한다 (메인 계좌 동결).
|
||||
backtest.apply_params(main_params)
|
||||
snap = _run_scan(pf, uni, quotes, cmkt, now)
|
||||
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))
|
||||
@@ -388,24 +466,50 @@ def scan_all(force: bool = False) -> dict:
|
||||
s = p.summary()
|
||||
return benchmark.compare(s.get('total_return_pct'), benchmark.norm_date(p.created_at), today)
|
||||
|
||||
compare = [{'id': 'main', 'name': '메인 (현재 튜닝)', 'params': main_params,
|
||||
'summary': pf.summary(), 'benchmark': _alpha(pf)}]
|
||||
compare = [] # 기준전략 폐지 — 변이들만 비교 (메인 계좌는 매매 안 함)
|
||||
for v in vdefs:
|
||||
backtest.apply_params(v.get('params') or {})
|
||||
vpf = vpfs[v['id']]
|
||||
_run_scan(vpf, uni, quotes, cmkt, now)
|
||||
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)})
|
||||
'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)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user