auto: 일일 백업 2026-06-10 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,12 +7,94 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from . import config, data, signals, universe
|
||||
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 _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():
|
||||
@@ -28,22 +110,52 @@ def _load_jsonl_last_per_market(path) -> dict[str, dict]:
|
||||
return out
|
||||
|
||||
|
||||
def market_ok_map() -> dict[str, bool]:
|
||||
"""시장별 신규매수 허용 여부 — 당일 상승비율 기반 소프트 필터 (EOD 데이터라 보수적)."""
|
||||
_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, bool] = {}
|
||||
out: dict[str, float] = {}
|
||||
for label, rec in latest.items():
|
||||
rise = rec.get('rise', 0)
|
||||
fall = rec.get('fall', 0)
|
||||
steady = rec.get('steady', 0)
|
||||
total = rise + fall + steady
|
||||
breadth = rise / total if total else 1.0
|
||||
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 >= config.MARKET_BREADTH_MIN
|
||||
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())
|
||||
@@ -101,6 +213,7 @@ def _gather(extra_codes=None):
|
||||
def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
"""주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X)."""
|
||||
mkt_ok = market_ok_map() # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산
|
||||
idx_ret = {mk: _index_return(mk, config.REL_STRENGTH_DAYS) for mk in ('KOSPI', 'KOSDAQ')}
|
||||
decisions: list[dict] = []
|
||||
buys: list[dict] = []
|
||||
sells: list[dict] = []
|
||||
@@ -125,7 +238,8 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
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)
|
||||
dec = signals.evaluate_holding(pf.positions[code], ind, flow, anl,
|
||||
held_days=_held_days(pf.positions[code], now))
|
||||
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')})
|
||||
@@ -154,6 +268,13 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
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,
|
||||
@@ -171,6 +292,12 @@ 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)
|
||||
# 상대강도(라이브 전용): 종목 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.can_open():
|
||||
|
||||
Reference in New Issue
Block a user