feat(sim): 관찰목록 자동편입/제외 — 키움 순위정보로 주도주 매일 편입
- kiwoom_client: get_volume_surge(ka10023)·get_foreign_inst_netbuy(ka90009) 순위 조회 추가 (rkinfo endpoint)
- universe: auto_import(외인·기관 순매수+거래량급증 상위, ETF·하락·급증률이상치 제외, 각15·총120 상한, origin=auto) + auto_prune(auto·미보유·10거래일 무신호만, 보유/수동/비하이브 불가침) + mark_signals
- engine.scan_all: 변이 watching 종목을 mark_signals로 신호 추적(auto_seen.json)
- CLI: python3 -m sim universe {import|prune|all}
- trade-journal plist 평일21:00에 'sim universe all' 4번째 명령 추가
- 첫 실행: 후보38 → 32편입 (59→91종목)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -136,13 +136,23 @@ def _cmd_reset(argv):
|
||||
print(f'초기화 완료 — 가상자본 {config.INITIAL_CAPITAL:,}원')
|
||||
|
||||
|
||||
def _cmd_universe(argv):
|
||||
"""관찰목록 자동편입/제외 — 매일 장 마감 후 1회. 'import'=주도주 편입, 'prune'=무신호 auto 제외, 둘 다 기본."""
|
||||
from . import universe
|
||||
sub = argv[0] if argv else 'all'
|
||||
if sub in ('import', 'all'):
|
||||
print('자동편입:', universe.auto_import())
|
||||
if sub in ('prune', 'all'):
|
||||
print('자동제외:', universe.auto_prune())
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
cmd = argv[0] if argv else 'status'
|
||||
rest = argv[1:]
|
||||
{'scan': _cmd_scan, 'status': _cmd_status, 'report': _cmd_report,
|
||||
'sweep': _cmd_sweep, 'backtest': _cmd_backtest, 'backfill': _cmd_backfill,
|
||||
'backfill-index': _cmd_backfill_index,
|
||||
'backfill-index': _cmd_backfill_index, 'universe': _cmd_universe,
|
||||
'variants': _cmd_variants, 'reset': _cmd_reset}.get(cmd, lambda a: print(__doc__))(rest)
|
||||
|
||||
|
||||
|
||||
@@ -472,6 +472,16 @@ def scan_all(force: bool = False) -> dict:
|
||||
'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,
|
||||
|
||||
@@ -151,6 +151,108 @@ def remove(code: str):
|
||||
_save_watchlist(wl)
|
||||
|
||||
|
||||
# ── 자동편입(auto) — 키움 순위정보로 주도주를 매일 후보에 편입 ──────────────
|
||||
# 소스: ka90009 외인·기관 순매수(수급 좋은 종목, 우리 전략 수급게이트와 궁합) + ka10023 거래량급증(관심 폭발).
|
||||
# 규칙: ETF/ETN·급증률 이상치·하락 제외, 각 소스 상위 N, 총 상한. 자동제외는 origin=auto·미보유·N거래일 무신호만.
|
||||
AUTO_TOP_N = 15 # 각 소스 상위 N 편입
|
||||
AUTO_TOTAL_CAP = 120 # 관찰목록 총 상한 (auto 편입은 이 한도 내에서만)
|
||||
AUTO_SURGE_CAP = 1000.0 # 거래량 급증률 이 % 초과는 노이즈로 제외
|
||||
AUTO_PRUNE_DAYS = 10 # auto 종목이 이 거래일 수 동안 무신호면 자동 제외 (보유·수동 등은 제외 안 함)
|
||||
|
||||
|
||||
def _held_codes() -> set:
|
||||
try:
|
||||
held = kc.get_positions_all(labels=config.OWNER_ACCOUNT_LABELS)
|
||||
return {(p.get('code') or '').strip().zfill(6)
|
||||
for poss in held.values() for p in poss if p.get('qty', 0) > 0}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def auto_import() -> dict:
|
||||
"""키움 순위정보로 주도주 자동 편입(origin='auto'). 총 상한 내에서만. 결과 요약 반환."""
|
||||
cands: dict[str, str] = {} # code -> name (중복 자동 dedup)
|
||||
|
||||
def take(rows, key_name='name', n=AUTO_TOP_N):
|
||||
c = 0
|
||||
for x in rows:
|
||||
if c >= n:
|
||||
break
|
||||
code = (x.get('code') or '').strip().zfill(6)
|
||||
nm = x.get(key_name, '')
|
||||
if not code or is_etf(nm) or code in cands:
|
||||
continue
|
||||
cands[code] = nm
|
||||
c += 1
|
||||
|
||||
try:
|
||||
nb = kc.get_foreign_inst_netbuy()
|
||||
take([x for x in nb['foreign'] if x['amt'] > 0]) # 외인 순매수
|
||||
take([x for x in nb['inst'] if x['amt'] > 0]) # 기관 순매수
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[universe] 외인기관 순매수 조회 실패: {e}\n')
|
||||
try:
|
||||
vs = kc.get_volume_surge()
|
||||
take([x for x in vs if x['flu_rt'] > 0 and 0 < x['surge_rt'] <= AUTO_SURGE_CAP]) # 거래량 급증
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[universe] 거래량급증 조회 실패: {e}\n')
|
||||
|
||||
wl = _load_watchlist()
|
||||
now = datetime.now(config.KST).isoformat()
|
||||
added = 0
|
||||
for code, nm in cands.items():
|
||||
if code in wl:
|
||||
continue
|
||||
if len(wl) >= AUTO_TOTAL_CAP: # 상한 도달 시 신규 auto 편입 중단
|
||||
break
|
||||
wl[code] = {'name': nm, 'origin': 'auto', 'added_at': now}
|
||||
added += 1
|
||||
if added:
|
||||
_save_watchlist(wl)
|
||||
return {'candidates': len(cands), 'added': added, 'total': len(wl)}
|
||||
|
||||
|
||||
def auto_prune(no_signal_days: int = AUTO_PRUNE_DAYS) -> dict:
|
||||
"""origin='auto'·미보유 종목 중 최근 no_signal_days 거래일 동안 한 번도 매수신호(BUY/ADD/WAIT-watch) 없던 것 제거.
|
||||
보유·수동·비하이브(watch/interest/held/manual)는 절대 건드리지 않는다."""
|
||||
wl = _load_watchlist()
|
||||
auto_codes = {c for c, e in wl.items() if e.get('origin') == 'auto'}
|
||||
if not auto_codes:
|
||||
return {'pruned': 0, 'total': len(wl)}
|
||||
held = _held_codes()
|
||||
# 최근 신호 추적: state/sim/auto_seen.json {code: last_signal_iso}. 매 import 시 갱신.
|
||||
seen = _load_json(config.STATE_DIR / 'auto_seen.json', {})
|
||||
now = datetime.now(config.KST)
|
||||
pruned = 0
|
||||
for code in list(auto_codes):
|
||||
if code in held:
|
||||
continue
|
||||
last = seen.get(code)
|
||||
ent_added = wl[code].get('added_at', '')
|
||||
ref = last or ent_added
|
||||
try:
|
||||
days = (now - datetime.fromisoformat(ref)).days
|
||||
except Exception:
|
||||
days = 0
|
||||
if days >= no_signal_days:
|
||||
del wl[code]
|
||||
pruned += 1
|
||||
if pruned:
|
||||
_save_watchlist(wl)
|
||||
return {'pruned': pruned, 'total': len(wl)}
|
||||
|
||||
|
||||
def mark_signals(codes_with_signal: list[str]):
|
||||
"""이번 스캔에서 매수신호(BUY/ADD/대기) 든 종목의 '마지막 신호 시각' 기록 — auto_prune 판단용."""
|
||||
path = config.STATE_DIR / 'auto_seen.json'
|
||||
seen = _load_json(path, {})
|
||||
now = datetime.now(config.KST).isoformat()
|
||||
for c in codes_with_signal:
|
||||
seen[(c or '').strip().zfill(6)] = now
|
||||
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(seen, ensure_ascii=False))
|
||||
|
||||
|
||||
def build_universe() -> list[dict]:
|
||||
"""누적 관찰목록을 동기화 후 [{code, name, sources:[origin]}]로 반환. code 6자리 정규화."""
|
||||
wl = sync_watchlist()
|
||||
|
||||
Reference in New Issue
Block a user