auto: 일일 백업 2026-06-09 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,9 @@
|
||||
| `holiday-sync` | 매주 일요일 03:00 | `holiday_sync.py` | investing.com → `state/market_holidays.json`. 실패 시 기존 파일 보존 |
|
||||
| `behive-web` | 상시 (`KeepAlive`) | `behive_web.py serve` | 3-탭 워치리스트 웹뷰. Tailscale `100.75.148.12:18790` → `https://stock.hyowons.net/`(Synology reverse proxy). 코드 변경 후 bootout/bootstrap 필요 |
|
||||
| `trade-journal` | 평일 21:00 (스크립트가 휴장일 self-skip) | `trade_journal.py collect` | ka10170 당일매매일지 4계좌 → `state/trade_journal.jsonl` 누적. NXT 야간 마감 후 발화. 키움이 기간 거래내역 API 미제공이라 일자별 적재만이 유일. `briefing-fallback-2100`(21:00)과 같은 시각이지만 둘 다 read-only 충돌 없음. 재실행 시 (date,account) 단위 idempotent. **시드**: 2026-05-13 1회 `trade_journal.py seed` 로 적재 시작일 이전 보유분을 현재 평단가×(qty - tdy_buyq + tdy_sellq)로 단일 시드 행 압축(28건, seed=true 플래그, `*` 마커). 시드는 가중평균이라 과거 매수 단가와 정확히 일치 X. CLI: `collect`/`seed`/`show <code>`/`query --from --to --account --code` |
|
||||
| `sim-scan` | 평일 09–15:30 매 15분 (`StartInterval` 900s, 엔진이 장외/휴장 self-skip) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1천만, 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유. 방향=애널 약한게이트+가점, 타이밍=수급·기술. 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` |
|
||||
| `sim-web` | 상시 (`KeepAlive`) | `python3 -m sim.sim_web serve` | 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **관리자님 수동 등록 대기**). Tailnet 한정 무인증. ⚠️ 18791은 node 점유라 18792. **5탭**: 내 계좌·관심종목·시장(지수/ADR/투자자)·튜닝(파라미터 저장 → `params.json`)·백테스트(스윕 순위표+적용). 읽기전용 원칙이나 `do_POST /save_params`만 쓰기. 시장 탭은 네이버 지수 60s 캐시 fetch(유일한 네트워크) |
|
||||
| (트리거 없음) | on-demand | `python3 -m sim {sweep\|backtest}` | 튜닝 비교용 백테스트 스윕. signals.py 재사용·과거 일봉 되감기. 4핵심(RR·STOP_ATR·SMA_LONG·PULLBACK_ATR) 81조합 학습/검증 분리 → `state/sim/backtest_results.json`. ⚠️ 종가체결·현 워치리스트(생존편향) → 절대수익 아닌 **상대순위**로만. 수급은 `backfill_flow.py`(ka10059 다년치 → `flow_history.sqlite`, 2026-06-08 54종목 3만행 적재)로 반영됨(`flow_used:true`). 런타임 튜닝은 `params.json`이 config 기본값 오버라이드(엔진 매 스캔 새프로세스라 즉시 반영). **병렬 페이퍼**: `variants.json`+`state/sim/variants/<id>/` — 스윕 상위 튜닝을 각자 가상계좌로 실시간 동시 운영, 같은 라이브 시세에 다른 파라미터(애널 게이트까지 실반영), `variants_compare.json`→웹 비교 탭. `python3 -m sim variants seed N`. sim-scan이 매 스캔 메인+변이 함께 갱신 |
|
||||
|
||||
휴장일 self-skip·당일 평가손익 ground truth·3-탭 개편 배경은 일지(2026-05-04·05-06)와 `behive_web.py` 주석 참조. 사고 이력은 `memory/2026-05-07-behive-dup-mail.md` 등.
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""주식 자동매매 시뮬레이션(페이퍼 트레이딩) 엔진.
|
||||
|
||||
실주문 orders/ 와 완전 분리 — 가상 자본으로만 동작한다.
|
||||
"""
|
||||
@@ -0,0 +1,144 @@
|
||||
"""sim CLI.
|
||||
|
||||
python3 -m sim scan [--force] # 1회 스캔 (launchd/cron 진입점)
|
||||
python3 -m sim status # 가상계좌 요약
|
||||
python3 -m sim report [N] # 최근 거래 N건 (기본 20)
|
||||
python3 -m sim reset [--yes] # 가상계좌 초기화 (1천만 리셋)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from . import config, engine
|
||||
from .portfolio import Portfolio
|
||||
|
||||
|
||||
def _cmd_scan(argv):
|
||||
snap = engine.scan_all(force='--force' in argv)
|
||||
if snap.get('skipped'):
|
||||
print(f"[skip] {snap['reason']} @ {snap['at']}")
|
||||
return
|
||||
s = snap['summary']
|
||||
print(f"자산 {s['equity']:,}원 ({s['total_return_pct']:+.2f}%) · 현금 {s['cash']:,} · "
|
||||
f"보유 {s['open_positions']} · 매수 {snap['counts']['buys']} 매도 {snap['counts']['sells']}")
|
||||
|
||||
|
||||
def _cmd_status(argv):
|
||||
pf = Portfolio.load()
|
||||
s = pf.summary()
|
||||
print(f"가상계좌 (초기 {s['initial']:,}원)")
|
||||
print(f" 평가자산 {s['equity']:,}원 ({s['total_return_pct']:+.2f}%)")
|
||||
print(f" 현금 {s['cash']:,} · 주식 {s['held_value']:,}")
|
||||
print(f" 실현손익 {s['realized_pnl']:,} · 청산 {s['closed_trades']}건 "
|
||||
f"승률 {s['win_rate_pct']}% · MDD {s['max_drawdown_pct']}%")
|
||||
if pf.positions:
|
||||
print(' 보유:')
|
||||
for p in pf.positions.values():
|
||||
cur = p.get('cur_price', p['entry_price'])
|
||||
pnl = (cur / p['entry_price'] - 1) * 100
|
||||
tr = ' [트레일링]' if p.get('trailing_on') else ''
|
||||
print(f" {p['name']:16s} {p['qty']}주 @{p['entry_price']:,} → {cur:,} "
|
||||
f"({pnl:+.1f}%) 손절 {p['stop']:,} 목표 {p['target']:,}{tr}")
|
||||
|
||||
|
||||
def _cmd_report(argv):
|
||||
n = next((int(a) for a in argv if a.isdigit()), 20)
|
||||
if not config.TRADES_PATH.exists():
|
||||
print('거래 없음')
|
||||
return
|
||||
lines = config.TRADES_PATH.read_text().splitlines()[-n:]
|
||||
for line in lines:
|
||||
r = json.loads(line)
|
||||
if r['side'] == 'BUY':
|
||||
print(f"{r['ts'][:16]} 매수 {r['name']:16s} {r['qty']}주 @{r['price']:,} · {r['reason']}")
|
||||
else:
|
||||
print(f"{r['ts'][:16]} 매도 {r['name']:16s} {r['qty']}주 @{r['price']:,} "
|
||||
f"손익 {r['pnl']:,}({r['pnl_pct']:+.1f}%) · {r['reason']}")
|
||||
|
||||
|
||||
def _cmd_sweep(argv):
|
||||
from . import sweep
|
||||
frac = next((float(a) for a in argv if a.replace('.', '').isdigit()), 0.7)
|
||||
res = sweep.run_sweep(train_frac=frac)
|
||||
if res.get('error'):
|
||||
print('스윕 실패:', res)
|
||||
return
|
||||
print(f"스윕 완료 — {res['count']}개 조합 · 검증 {res['split']['test']} "
|
||||
f"· 수급반영 {res['flow_used']}")
|
||||
for r in res['results'][:10]:
|
||||
p, te = r['params'], r['test']
|
||||
print(f" RR{p['RR_RATIO']} S{p['STOP_ATR_MULT']} SMA{p['SMA_LONG']} P{p['PULLBACK_ATR_MULT']} → "
|
||||
f"검증 {te['total_return_pct']:+}% MDD {te['mdd_pct']}% 거래 {te['trades']} 승률 {te['win_rate_pct']}%")
|
||||
|
||||
|
||||
def _cmd_backfill(argv):
|
||||
from . import backfill_flow
|
||||
pages = int(argv[argv.index('--pages') + 1]) if '--pages' in argv else backfill_flow.DEFAULT_PAGES
|
||||
res = backfill_flow.backfill(pages=pages, force='--force' in argv)
|
||||
print(f"수급 백필 — 신규 {res['fetched']} · 스킵 {res['skipped']} · 실패 {res['failed']} "
|
||||
f"· DB {res['codes_in_db']}종목 {res['total_rows']}행")
|
||||
|
||||
|
||||
def _cmd_backtest(argv):
|
||||
from . import backtest, universe
|
||||
codes = [e['code'] for e in universe.build_universe()]
|
||||
import json
|
||||
print(json.dumps(backtest.run(None, codes), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def _cmd_backfill_index(argv):
|
||||
from . import benchmark
|
||||
pages = int(argv[argv.index('--pages') + 1]) if '--pages' in argv else 8
|
||||
res = benchmark.backfill(pages=pages)
|
||||
print(f"지수 백필 — 신규 {res}")
|
||||
for idx in benchmark.INDICES:
|
||||
ks = sorted(benchmark.series(idx))
|
||||
if ks:
|
||||
print(f" {idx}: {ks[0]} ~ {ks[-1]} ({len(ks)}일)")
|
||||
|
||||
|
||||
def _cmd_variants(argv):
|
||||
from . import variants
|
||||
sub = argv[0] if argv else 'list'
|
||||
if sub == 'seed':
|
||||
n = next((int(a) for a in argv[1:] if a.isdigit()), 3)
|
||||
vs = variants.seed_from_sweep(n)
|
||||
print(f'변이 {len(vs)}개 등록:')
|
||||
for v in vs:
|
||||
print(' ', v['id'], v['name'])
|
||||
elif sub == 'reset':
|
||||
variants.reset_all()
|
||||
print('변이 계좌 초기화 (정의 유지)')
|
||||
elif sub == 'clear':
|
||||
variants.clear()
|
||||
print('변이 정의·계좌 전부 삭제')
|
||||
else:
|
||||
vs = variants.load_variants()
|
||||
print(f'변이 {len(vs)}개:')
|
||||
for v in vs:
|
||||
print(' ', v['id'], v['name'], v['params'])
|
||||
|
||||
|
||||
def _cmd_reset(argv):
|
||||
if '--yes' not in argv:
|
||||
print('정말 초기화하려면 --yes 를 붙이세요 (가상계좌·거래·스냅샷 삭제)')
|
||||
return
|
||||
for p in (config.PORTFOLIO_PATH, config.TRADES_PATH, config.LAST_SCAN_PATH):
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
print(f'초기화 완료 — 가상자본 {config.INITIAL_CAPITAL:,}원')
|
||||
|
||||
|
||||
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,
|
||||
'variants': _cmd_variants, 'reset': _cmd_reset}.get(cmd, lambda a: print(__doc__))(rest)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""수급 백필 — ka10059 페이지네이션으로 종목별 투자자 순매수 다년치 → flow_history.sqlite.
|
||||
|
||||
백테스트가 수급(외국인·기관)을 반영하려면 과거 일자별 순매수가 필요한데 키움은 단일 콜 100일뿐
|
||||
이라 연속조회로 깊이 받아 로컬 적재한다. 1회성(또는 가끔) 실행, idempotent(코드+일자 upsert).
|
||||
|
||||
스키마: flow(code, date, foreign_net, inst_net, indiv_net) 단위 천주, +순매수/−순매도.
|
||||
backtest.load_flow 가 (foreign_net, inst_net) 를 읽는다.
|
||||
|
||||
rate limit(ka10059 stkinfo): 콜·종목 간 sleep + 429 백오프 재시도.
|
||||
|
||||
CLI:
|
||||
python3 -m sim.backfill_flow [--pages N] [--force] [code ...]
|
||||
--pages N : 종목당 페이지수(100거래일/페이지, 기본 6 ≈ 2.4년)
|
||||
--force : 이미 충분히 쌓인 종목도 다시 받음
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from . import config, universe
|
||||
|
||||
sys.path.insert(0, str(config.SCRIPTS))
|
||||
import kiwoom_client as kc # noqa: E402
|
||||
|
||||
FLOW_DB = config.STATE_DIR / 'flow_history.sqlite'
|
||||
DEFAULT_PAGES = 6 # 100거래일 × 6 ≈ 2.4년
|
||||
PAGE_SLEEP = 0.35 # 페이지 간 간격
|
||||
CODE_SLEEP = 0.5 # 종목 간 간격
|
||||
RATE_BACKOFF = 3.0 # 429 시 추가 대기
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
FLOW_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
c = sqlite3.connect(FLOW_DB, isolation_level=None)
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS flow (
|
||||
code TEXT NOT NULL, date TEXT NOT NULL,
|
||||
foreign_net INTEGER, inst_net INTEGER, indiv_net INTEGER,
|
||||
PRIMARY KEY (code, date)
|
||||
)""")
|
||||
return c
|
||||
|
||||
|
||||
def _is_rate_limit(resp: dict) -> bool:
|
||||
return resp.get('return_code', 0) != 0 and (
|
||||
'허용된 요청' in str(resp.get('return_msg') or '') or resp.get('return_code') == 5)
|
||||
|
||||
|
||||
def fetch_code(code: str, pages: int = DEFAULT_PAGES) -> list[dict]:
|
||||
"""ka10059 연속조회 — 최신부터 pages 페이지(페이지당 100거래일) 누적. 최신순 list."""
|
||||
label = kc._default_account_label()
|
||||
today = datetime.now(config.KST).strftime('%Y%m%d')
|
||||
body = {'dt': today, 'stk_cd': kc._clean_code(code),
|
||||
'amt_qty_tp': '2', 'trde_tp': '0', 'unit_tp': '1000'}
|
||||
url = kc.base_url() + kc.ENDPOINT_STKINFO
|
||||
cont, nk = 'N', ''
|
||||
out: list[dict] = []
|
||||
for _page in range(pages):
|
||||
headers = kc.auth_headers(label, tr_id=kc.TR_INVESTOR_FLOW, cont_yn=cont, next_key=nk)
|
||||
resp, hd = kc._http_post_full(url, body, headers)
|
||||
if _is_rate_limit(resp):
|
||||
time.sleep(RATE_BACKOFF)
|
||||
resp, hd = kc._http_post_full(url, body, headers)
|
||||
if resp.get('return_code', 0) != 0:
|
||||
sys.stderr.write(f'[backfill] {code} page 실패: {resp.get("return_msg")}\n')
|
||||
break
|
||||
for r in resp.get('stk_invsr_orgn') or []:
|
||||
d = (r.get('dt') or '').strip()
|
||||
if d:
|
||||
out.append({'date': d, 'foreign': kc._to_int(r.get('frgnr_invsr')),
|
||||
'institution': kc._to_int(r.get('orgn')),
|
||||
'individual': kc._to_int(r.get('ind_invsr'))})
|
||||
cy = (hd.get('cont-yn') or '').strip().upper()
|
||||
nkk = (hd.get('next-key') or '').strip()
|
||||
if cy == 'Y' and nkk:
|
||||
cont, nk = 'Y', nkk
|
||||
time.sleep(PAGE_SLEEP)
|
||||
else:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _existing_count(c: sqlite3.Connection, code: str) -> int:
|
||||
return c.execute('SELECT COUNT(*) FROM flow WHERE code=?', (code,)).fetchone()[0]
|
||||
|
||||
|
||||
def backfill(codes: list[str] | None = None, pages: int = DEFAULT_PAGES, force: bool = False) -> dict:
|
||||
codes = codes or [e['code'] for e in universe.build_universe()]
|
||||
c = _conn()
|
||||
target = pages * 100
|
||||
done = skipped = failed = 0
|
||||
for code in codes:
|
||||
if not force and _existing_count(c, code) >= target * 0.9:
|
||||
skipped += 1
|
||||
continue
|
||||
rows = fetch_code(code, pages)
|
||||
if not rows:
|
||||
failed += 1
|
||||
continue
|
||||
c.executemany('INSERT OR REPLACE INTO flow(code,date,foreign_net,inst_net,indiv_net) VALUES (?,?,?,?,?)',
|
||||
[(code, r['date'], r['foreign'], r['institution'], r['individual']) for r in rows])
|
||||
done += 1
|
||||
sys.stderr.write(f'[backfill] {code}: {len(rows)}행 ({rows[-1]["date"]}~{rows[0]["date"]})\n')
|
||||
time.sleep(CODE_SLEEP)
|
||||
total = c.execute('SELECT COUNT(*) FROM flow').fetchone()[0]
|
||||
n_codes = c.execute('SELECT COUNT(DISTINCT code) FROM flow').fetchone()[0]
|
||||
c.close()
|
||||
return {'fetched': done, 'skipped': skipped, 'failed': failed,
|
||||
'total_rows': total, 'codes_in_db': n_codes}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
argv = sys.argv[1:]
|
||||
force = '--force' in argv
|
||||
pages = DEFAULT_PAGES
|
||||
if '--pages' in argv:
|
||||
pages = int(argv[argv.index('--pages') + 1])
|
||||
explicit = [a for a in argv if a.isdigit() and len(a) >= 4]
|
||||
res = backfill(codes=explicit or None, pages=pages, force=force)
|
||||
print(f"백필 완료 — 신규 {res['fetched']} · 스킵 {res['skipped']} · 실패 {res['failed']} "
|
||||
f"· DB {res['codes_in_db']}종목 {res['total_rows']}행")
|
||||
@@ -0,0 +1,226 @@
|
||||
"""백테스트 엔진 — 과거 일봉을 하루씩 되감으며 signals.py 규칙으로 가상 매매.
|
||||
|
||||
미래정보 차단:
|
||||
- 지표는 그날(t)까지의 봉으로만 계산, 체결은 당일 종가 (다음 봉 미참조).
|
||||
- 애널 게이트는 과거 재현 불가 → 중립(analyst=None, 자동 통과).
|
||||
- 수급은 flow_history(있으면) 사용, 없으면 중립(통과)으로 표시.
|
||||
- 시장 필터(ADR)는 데이터가 짧아 backtest 에선 통과 처리.
|
||||
|
||||
엔진과 동일한 signals.compute_indicators / evaluate_candidate / evaluate_holding /
|
||||
compute_stop_target 를 그대로 재사용한다 (페이퍼와 같은 두뇌).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
from . import config, signals
|
||||
|
||||
sys.path.insert(0, str(config.SCRIPTS))
|
||||
import daily_candles_cache as dcc # noqa: E402
|
||||
|
||||
FLOW_DB = config.STATE_DIR / 'flow_history.sqlite'
|
||||
WINDOW = 140 # 지표 계산에 넘길 최근 봉 수 (성능 상한 — 모든 지표 기간 + 버퍼 충분)
|
||||
|
||||
|
||||
def apply_params(overrides: dict | None):
|
||||
"""튜닝 오버라이드를 config 전역에 반영 (없는 키는 기본값). 각 run 독립 보장."""
|
||||
overrides = overrides or {}
|
||||
for spec in config.TUNABLE:
|
||||
k = spec['key']
|
||||
v = overrides.get(k, config._DEFAULTS[k])
|
||||
setattr(config, k, v)
|
||||
|
||||
|
||||
def load_history(code: str, count: int = 600) -> list[dict]:
|
||||
"""캐시(sqlite)에서 일봉 오름차순. 네트워크 X."""
|
||||
return list(reversed(dcc._select_latest(code, count)))
|
||||
|
||||
|
||||
def load_flow(code: str) -> dict | None:
|
||||
"""flow_history.sqlite 에서 {date: (foreign, institution)}. 없으면 None (수급 중립)."""
|
||||
if not FLOW_DB.exists():
|
||||
return None
|
||||
try:
|
||||
c = sqlite3.connect(FLOW_DB)
|
||||
rows = c.execute('SELECT date, foreign_net, inst_net FROM flow WHERE code=? ORDER BY date', (code,)).fetchall()
|
||||
c.close()
|
||||
return {r[0]: (r[1], r[2]) for r in rows} or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _flow_net_upto(flow: dict | None, dates_seen: list[str]) -> dict:
|
||||
"""최근 FLOW_DAYS 일 외국인·기관 누적. flow 없으면 중립(둘 다 +1 → 수급 통과)."""
|
||||
if flow is None:
|
||||
return {'foreign': 1, 'institution': 1, 'days': 0, 'neutral': True}
|
||||
recent = dates_seen[-config.FLOW_DAYS:]
|
||||
f = sum(flow.get(d, (0, 0))[0] for d in recent)
|
||||
i = sum(flow.get(d, (0, 0))[1] for d in recent)
|
||||
return {'foreign': f, 'institution': i, 'days': len(recent)}
|
||||
|
||||
|
||||
def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to: str = '9',
|
||||
capital: float | None = None) -> dict:
|
||||
"""백테스트 1회. 반환: 지표 dict (+ trades 수)."""
|
||||
apply_params(overrides)
|
||||
capital = capital or config.INITIAL_CAPITAL
|
||||
|
||||
hist = {}
|
||||
for code in codes:
|
||||
h = [c for c in load_history(code) if date_from <= c['date'] <= date_to]
|
||||
if len(h) > config.SMA_LONG + config.ATR_PERIOD + 5:
|
||||
hist[code] = h
|
||||
if not hist:
|
||||
return {'error': 'no_data', 'trades': 0}
|
||||
flows = {code: load_flow(code) for code in hist}
|
||||
|
||||
# code별 date→index, 전체 거래일 축
|
||||
idx_map = {code: {c['date']: i for i, c in enumerate(h)} for code, h in hist.items()}
|
||||
all_dates = sorted({c['date'] for h in hist.values() for c in h})
|
||||
|
||||
cash = capital
|
||||
positions: dict[str, dict] = {}
|
||||
realized = 0.0
|
||||
wins = closed = 0
|
||||
gross_win = gross_loss = 0.0
|
||||
peak_eq = capital
|
||||
max_dd = 0.0
|
||||
comm, tax = config.COMMISSION_RATE, config.SELL_TAX_RATE
|
||||
|
||||
def equity(day_prices):
|
||||
return cash + sum(p['qty'] * day_prices.get(c, p['entry_price']) for c, p in positions.items())
|
||||
|
||||
def _qty_for(fill, value):
|
||||
return int(value // (fill * (1 + comm)))
|
||||
|
||||
for day in all_dates:
|
||||
day_prices = {}
|
||||
# ---- 보유 판단 (당일 종가 기준): 손절/익절(전량·분할) → 추격매수 ----
|
||||
for code in list(positions.keys()):
|
||||
h = hist.get(code)
|
||||
i = idx_map[code].get(day)
|
||||
if i is None:
|
||||
continue
|
||||
window = h[max(0, i - WINDOW):i + 1]
|
||||
ind = signals.compute_indicators(window)
|
||||
if ind is None:
|
||||
continue
|
||||
day_prices[code] = ind['price']
|
||||
dates_seen = [c['date'] for c in h[:i + 1]]
|
||||
flow = _flow_net_upto(flows.get(code), dates_seen)
|
||||
pos = positions[code]
|
||||
dec = signals.evaluate_holding(pos, ind, flow, None)
|
||||
pos.update(dec['position_update'])
|
||||
act = dec['action']
|
||||
if act in ('sell', 'scale_out'):
|
||||
fill = ind['price']
|
||||
total = pos['qty']
|
||||
qty = total if dec.get('sell_frac', 1.0) >= 1.0 else max(1, int(total * dec['sell_frac']))
|
||||
qty = min(qty, total)
|
||||
proceeds = qty * fill * (1 - comm - tax)
|
||||
cost = qty * pos['entry_price'] * (1 + comm)
|
||||
pnl = proceeds - cost
|
||||
cash += proceeds
|
||||
realized += pnl
|
||||
if qty >= total:
|
||||
positions.pop(code)
|
||||
closed += 1
|
||||
if pnl > 0:
|
||||
wins += 1
|
||||
else:
|
||||
pos['qty'] = total - qty
|
||||
if pnl > 0:
|
||||
gross_win += pnl
|
||||
else:
|
||||
gross_loss += -pnl
|
||||
elif act == 'add':
|
||||
fill = ind['price']
|
||||
tranche_val = pos.get('tranche_value') or (pos['entry_price'] * pos['qty'])
|
||||
qty = _qty_for(fill, tranche_val)
|
||||
cost = qty * fill * (1 + comm)
|
||||
if qty >= 1 and cost <= cash:
|
||||
new_qty = pos['qty'] + qty
|
||||
pos['entry_price'] = (pos['entry_price'] * pos['qty'] + fill * qty) / new_qty
|
||||
pos['qty'] = new_qty
|
||||
pos['tranches'] = pos.get('tranches', 1) + 1
|
||||
pos['last_add_price'] = fill
|
||||
cash -= cost
|
||||
nstop, ntarget = signals.compute_stop_target(pos['entry_price'], ind['atr'], ind['recent_low'])
|
||||
pos['stop'] = max(pos['stop'], nstop)
|
||||
pos['target'] = ntarget
|
||||
|
||||
# ---- 신규 매수 판단 (1차 트랜치) ----
|
||||
for code, h in hist.items():
|
||||
if code in positions or len(positions) >= config.MAX_POSITIONS:
|
||||
continue
|
||||
i = idx_map[code].get(day)
|
||||
if i is None:
|
||||
continue
|
||||
window = h[max(0, i - WINDOW):i + 1]
|
||||
ind = signals.compute_indicators(window)
|
||||
if ind is None:
|
||||
continue
|
||||
day_prices[code] = ind['price']
|
||||
if not signals.trend_ok(ind):
|
||||
continue
|
||||
dates_seen = [c['date'] for c in h[:i + 1]]
|
||||
flow = _flow_net_upto(flows.get(code), dates_seen)
|
||||
dec = signals.evaluate_candidate(code, code, [], ind, flow, None, True)
|
||||
if dec['action'] == 'buy':
|
||||
fill = dec['buy_price']
|
||||
eq = equity(day_prices)
|
||||
tranche_val = (eq / config.MAX_POSITIONS) / max(1, config.ENTRY_TRANCHES)
|
||||
qty = _qty_for(fill, tranche_val)
|
||||
cost = qty * fill * (1 + comm)
|
||||
if qty < 1 or cost > cash:
|
||||
continue
|
||||
stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low'])
|
||||
cash -= cost
|
||||
positions[code] = {'code': code, 'name': code, 'qty': qty, 'entry_price': fill,
|
||||
'stop': stop, 'target': target, 'peak': fill, 'trailing_on': False,
|
||||
'scaled_out': False, 'tranches': 1, 'tranche_value': tranche_val,
|
||||
'last_add_price': fill}
|
||||
|
||||
eq = equity(day_prices)
|
||||
if eq > peak_eq:
|
||||
peak_eq = eq
|
||||
dd = (peak_eq - eq) / peak_eq if peak_eq else 0
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
|
||||
# 마지막 날 종가로 잔여 포지션 청산 평가 (미실현 포함 최종자산)
|
||||
final_prices = {}
|
||||
for code, p in positions.items():
|
||||
h = hist[code]
|
||||
final_prices[code] = h[-1]['close']
|
||||
final_eq = cash + sum(p['qty'] * final_prices[c] for c, p in positions.items())
|
||||
|
||||
n_days = len(all_dates)
|
||||
total_ret = (final_eq / capital - 1) * 100
|
||||
years = n_days / 252 if n_days else 0
|
||||
cagr = ((final_eq / capital) ** (1 / years) - 1) * 100 if years > 0 and final_eq > 0 else 0
|
||||
pf = (gross_win / gross_loss) if gross_loss > 0 else (math.inf if gross_win > 0 else 0)
|
||||
|
||||
d_from = all_dates[0] if all_dates else None
|
||||
d_to = all_dates[-1] if all_dates else None
|
||||
from . import benchmark
|
||||
bench = benchmark.compare(round(total_ret, 2), d_from, d_to) if d_from else {}
|
||||
|
||||
return {
|
||||
'final_equity': round(final_eq),
|
||||
'total_return_pct': round(total_ret, 2),
|
||||
'cagr_pct': round(cagr, 2),
|
||||
'mdd_pct': round(max_dd * 100, 2),
|
||||
'trades': closed,
|
||||
'open_positions': len(positions),
|
||||
'win_rate_pct': round(wins / closed * 100, 1) if closed else None,
|
||||
'profit_factor': round(pf, 2) if pf != math.inf else 'inf',
|
||||
'days': n_days,
|
||||
'from': d_from,
|
||||
'to': d_to,
|
||||
'flow_used': any(f is not None for f in flows.values()),
|
||||
'benchmark': bench,
|
||||
'alpha_pct': bench.get('KOSPI', {}).get('alpha'),
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""기준지수(KOSPI/KOSDAQ) 일별 종가 캐시 + 벤치마크 수익률·알파.
|
||||
|
||||
네이버 m.stock 지수 일별시세 API에서 받아 state/sim/index_history.json 에 누적.
|
||||
sim/백테스트 수익률을 '같은 기간 지수 매수후보유' 대비(알파)로 평가하기 위한 데이터원.
|
||||
실패해도 raise 하지 않고 None 으로 흘려보낸다 (지표 부재 ≠ 엔진 중단).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
from . import config
|
||||
|
||||
INDEX_PATH = config.STATE_DIR / 'index_history.json'
|
||||
INDICES = ('KOSPI', 'KOSDAQ')
|
||||
INDEX_LABEL = {'KOSPI': '코스피', 'KOSDAQ': '코스닥'}
|
||||
|
||||
|
||||
def norm_date(d: str) -> str:
|
||||
"""'2026-06-09', '2026-06-09T...', '20260609' → 'YYYYMMDD'."""
|
||||
return (d or '').replace('-', '')[:8]
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
try:
|
||||
return json.loads(INDEX_PATH.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
INDEX_PATH.write_text(json.dumps(data, ensure_ascii=False))
|
||||
|
||||
|
||||
def _fetch_page(index: str, page: int, size: int) -> list[dict]:
|
||||
url = f'https://m.stock.naver.com/api/index/{index}/price?pageSize={size}&page={page}'
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=6.0) as r:
|
||||
return json.loads(r.read().decode('utf-8', 'ignore')) or []
|
||||
|
||||
|
||||
def backfill(pages: int = 8, size: int = 50) -> dict:
|
||||
"""KOSPI/KOSDAQ 일별 종가를 pages×size 만큼 받아 캐시에 병합(idempotent). {index: 신규건수}."""
|
||||
data = _load()
|
||||
added: dict[str, int] = {}
|
||||
for idx in INDICES:
|
||||
store = data.setdefault(idx, {})
|
||||
n0 = len(store)
|
||||
for p in range(1, pages + 1):
|
||||
try:
|
||||
rows = _fetch_page(idx, p, size)
|
||||
except Exception:
|
||||
break
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
d = norm_date(row.get('localTradedAt'))
|
||||
c = row.get('closePrice')
|
||||
if not d or not c:
|
||||
continue
|
||||
try:
|
||||
store[d] = float(str(c).replace(',', ''))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
added[idx] = len(store) - n0
|
||||
_save(data)
|
||||
return added
|
||||
|
||||
|
||||
def update_today() -> None:
|
||||
"""최신 1페이지만 받아 오늘 종가 갱신 (스캔마다 저비용 호출, 실패 무시)."""
|
||||
try:
|
||||
backfill(pages=1, size=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def series(index: str) -> dict:
|
||||
return _load().get(index, {})
|
||||
|
||||
|
||||
def _nearest(store: dict, date: str, after: bool):
|
||||
"""date 기준 on-or-after(after=True) / on-or-before 가장 가까운 (date, close). 없으면 None."""
|
||||
if not store:
|
||||
return None
|
||||
d = norm_date(date)
|
||||
keys = sorted(store)
|
||||
if after:
|
||||
cand = [k for k in keys if k >= d]
|
||||
k = cand[0] if cand else None
|
||||
else:
|
||||
cand = [k for k in keys if k <= d]
|
||||
k = cand[-1] if cand else None
|
||||
return (k, store[k]) if k else None
|
||||
|
||||
|
||||
def benchmark_return(index: str, date_from: str, date_to: str):
|
||||
"""[from, to] 구간 지수 등락률(%). from=on-or-after, to=on-or-before 종가. 데이터 없으면 None."""
|
||||
store = series(index)
|
||||
a = _nearest(store, date_from, after=True)
|
||||
b = _nearest(store, date_to, after=False)
|
||||
if not a or not b or a[1] <= 0 or b[0] <= a[0]:
|
||||
return None
|
||||
return round((b[1] / a[1] - 1) * 100, 2)
|
||||
|
||||
|
||||
def compare(return_pct, date_from: str, date_to: str) -> dict:
|
||||
"""기간 수익률(return_pct)을 지수 대비로 비교. {index: {'pct':지수등락, 'alpha':초과}}."""
|
||||
out: dict[str, dict] = {}
|
||||
for idx in INDICES:
|
||||
b = benchmark_return(idx, date_from, date_to)
|
||||
alpha = round(return_pct - b, 2) if (b is not None and return_pct is not None) else None
|
||||
out[idx] = {'pct': b, 'alpha': alpha}
|
||||
return out
|
||||
@@ -0,0 +1,158 @@
|
||||
"""sim 엔진 설정 — 상수·경로. 기본값은 여기, 런타임 튜닝은 state/sim/params.json 오버라이드."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo('Asia/Seoul')
|
||||
|
||||
WORKSPACE = Path(__file__).resolve().parent.parent # agents/stock/workspace
|
||||
SCRIPTS = WORKSPACE / 'scripts'
|
||||
STATE_DIR = WORKSPACE / 'state' / 'sim'
|
||||
HOLIDAYS_PATH = WORKSPACE / 'state' / 'market_holidays.json'
|
||||
MARKET_HISTORY = WORKSPACE / 'state' / 'market_indicators_history.jsonl'
|
||||
|
||||
# sim state 파일
|
||||
PORTFOLIO_PATH = STATE_DIR / 'portfolio.json'
|
||||
TRADES_PATH = STATE_DIR / 'trades.jsonl'
|
||||
LAST_SCAN_PATH = STATE_DIR / 'last_scan.json'
|
||||
|
||||
# 보유종목 universe 수집 대상 계좌 (본인만)
|
||||
OWNER_ACCOUNT_LABELS = ['일반', 'ISA']
|
||||
|
||||
# ---- 자본·체결·비용 ----
|
||||
INITIAL_CAPITAL = 10_000_000 # 가상 자본
|
||||
COMMISSION_RATE = 0.00015 # 매매 수수료 (편도, 키움 ~0.015%)
|
||||
SELL_TAX_RATE = 0.0018 # 매도 증권거래세 (~0.18%)
|
||||
|
||||
# ---- 포지션 사이징 ----
|
||||
MAX_POSITIONS = 8 # 동시 보유 최대 종목수
|
||||
# 종목당 목표 비중 = 자산 / MAX_POSITIONS
|
||||
|
||||
# ---- 지표 기간 ----
|
||||
SMA_SHORT = 5
|
||||
SMA_LONG = 20
|
||||
ATR_PERIOD = 14
|
||||
BREAKOUT_LOOKBACK = 20 # 돌파 판정용 최근 고점 구간
|
||||
SWING_LOW_LOOKBACK = 10 # 손절선 후보 스윙 저점 구간
|
||||
VOLUME_AVG_PERIOD = 20
|
||||
FLOW_DAYS = 5 # 수급 합산 일수 (외국인·기관 N일 순매수)
|
||||
|
||||
# ---- 매수 ----
|
||||
VOLUME_BREAKOUT_MULT = 1.5 # 돌파 매수: 거래량 ≥ 평균 × 이 배수
|
||||
TURNOVER_BREAKOUT_MULT = 1.5 # 돌파 확인: 오늘 회전율 ≥ 20일 평균 회전율 × 이 배수 (데이터 없으면 통과)
|
||||
TURNOVER_OVERHEAT_MULT = 3.0 # 과열 보류: 오늘 회전율 ≥ 평균 × 이 배수면 신규매수 보류 (돌파 배수보다 높게)
|
||||
PULLBACK_ATR_MULT = 1.0 # 눌림목 지정가 = 전일종가 − ATR × 이 배수 (20일선과 비교 후 위쪽)
|
||||
|
||||
# ---- 분할매수·추격매수 ----
|
||||
ENTRY_TRANCHES = 2 # 종목당 목표비중을 이 횟수로 나눠 진입 (1=전량 1회). 1차 = 목표/이 값
|
||||
ADD_ATR_MULT = 0.5 # 추격매수: 직전 진입가 + ATR × 이 배수 돌파 & 추세 지속 시 다음 트랜치 추가
|
||||
|
||||
# ---- 매도 ----
|
||||
STOP_ATR_MULT = 2.0 # 손절 = 진입가 − ATR × 이 배수 (스윙저점과 비교 후 위쪽)
|
||||
RR_RATIO = 2.0 # 목표 = 진입가 + (진입가−손절) × 이 배수
|
||||
SCALE_OUT_FRAC = 0.0 # 분할매도: 목표 도달 시 보유량의 이 비율 익절 후 잔량 트레일링 (0=분할매도 끔, 목표 도달 시 전량 트레일링 전환)
|
||||
TRAIL_ATR_MULT = 1.5 # 트레일링: 고점 − ATR × 이 배수
|
||||
TRAIL_ATR_MULT_TIGHT = 1.0 # 컨센 목표가 부근에서 트레일링 강화
|
||||
|
||||
# ---- 애널리스트 게이트 (약한 게이트) ----
|
||||
ANALYST_MIN_UPSIDE_PCT = 5.0 # 컨센 목표주가 상승여력 < 이 값이면 신규매수 제외
|
||||
ANALYST_SELL_OPINION_MAX = 2.5 # 투자의견(1매도~5매수) 평균이 이 값 이하면 매도 의견으로 보고 제외
|
||||
|
||||
# ---- 시장 필터 (소프트) ----
|
||||
MARKET_BREADTH_MIN = 0.15 # 해당 시장 당일 상승비율 < 이 값이면 그 시장 신규매수 보류
|
||||
|
||||
# ---- 운영 시간 (KST) ----
|
||||
MARKET_OPEN = (9, 0)
|
||||
MARKET_CLOSE = (15, 30)
|
||||
|
||||
|
||||
# ==== 런타임 튜닝 ====
|
||||
# 웹 튜닝 화면이 노출·수정하는 파라미터. state/sim/params.json 에 저장되며 config import 시 위 기본값을 덮어쓴다.
|
||||
# 엔진은 매 스캔마다 새 프로세스라 저장 즉시(다음 스캔부터) 반영된다.
|
||||
PARAMS_PATH = STATE_DIR / 'params.json'
|
||||
|
||||
TUNABLE = [
|
||||
{'key': 'SMA_SHORT', 'label': '단기 이동평균(일)', 'type': 'int', 'min': 2, 'max': 60, 'step': 1, 'group': '추세'},
|
||||
{'key': 'SMA_LONG', 'label': '장기 이동평균(일)', 'type': 'int', 'min': 5, 'max': 200, 'step': 1, 'group': '추세'},
|
||||
{'key': 'ATR_PERIOD', 'label': 'ATR 기간(일)', 'type': 'int', 'min': 5, 'max': 60, 'step': 1, 'group': '변동성·기간'},
|
||||
{'key': 'BREAKOUT_LOOKBACK', 'label': '돌파 고점 구간(일)', 'type': 'int', 'min': 5, 'max': 120, 'step': 1, 'group': '변동성·기간'},
|
||||
{'key': 'SWING_LOW_LOOKBACK', 'label': '스윙 저점 구간(일)', 'type': 'int', 'min': 3, 'max': 60, 'step': 1, 'group': '변동성·기간'},
|
||||
{'key': 'VOLUME_AVG_PERIOD', 'label': '거래량 평균 구간(일)', 'type': 'int', 'min': 5, 'max': 120, 'step': 1, 'group': '변동성·기간'},
|
||||
{'key': 'FLOW_DAYS', 'label': '수급 합산(일)', 'type': 'int', 'min': 1, 'max': 20, 'step': 1, 'group': '변동성·기간'},
|
||||
{'key': 'VOLUME_BREAKOUT_MULT', 'label': '돌파 거래량 배수', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '매수'},
|
||||
{'key': 'TURNOVER_BREAKOUT_MULT', 'label': '돌파 회전율 배수', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '매수'},
|
||||
{'key': 'TURNOVER_OVERHEAT_MULT', 'label': '과열 보류 회전율 배수', 'type': 'float', 'min': 1.5, 'max': 10.0, 'step': 0.1, 'group': '매수'},
|
||||
{'key': 'PULLBACK_ATR_MULT', 'label': '눌림목 ATR 배수', 'type': 'float', 'min': 0.0, 'max': 3.0, 'step': 0.1, 'group': '매수'},
|
||||
{'key': 'ENTRY_TRANCHES', 'label': '분할매수 횟수', 'type': 'int', 'min': 1, 'max': 5, 'step': 1, 'group': '매수'},
|
||||
{'key': 'ADD_ATR_MULT', 'label': '추격매수 ATR 배수', 'type': 'float', 'min': 0.3, 'max': 3.0, 'step': 0.1, 'group': '매수'},
|
||||
{'key': 'STOP_ATR_MULT', 'label': '손절 ATR 배수', 'type': 'float', 'min': 0.5, 'max': 5.0, 'step': 0.1, 'group': '매도'},
|
||||
{'key': 'RR_RATIO', 'label': '손익비(목표/손절)', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '매도'},
|
||||
{'key': 'SCALE_OUT_FRAC', 'label': '분할매도 익절 비율', 'type': 'float', 'min': 0.0, 'max': 1.0, 'step': 0.05, 'group': '매도'},
|
||||
{'key': 'TRAIL_ATR_MULT', 'label': '트레일링 ATR 배수', 'type': 'float', 'min': 0.5, 'max': 5.0, 'step': 0.1, 'group': '매도'},
|
||||
{'key': 'TRAIL_ATR_MULT_TIGHT', 'label': '트레일링 강화 배수', 'type': 'float', 'min': 0.3, 'max': 3.0, 'step': 0.1, 'group': '매도'},
|
||||
{'key': 'ANALYST_MIN_UPSIDE_PCT', 'label': '최소 상승여력(%)', 'type': 'float', 'min': -50.0, 'max': 100.0, 'step': 1.0, 'group': '애널·시장'},
|
||||
{'key': 'ANALYST_SELL_OPINION_MAX', 'label': '매도의견 컷(1~5)', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '애널·시장'},
|
||||
{'key': 'MARKET_BREADTH_MIN', 'label': '시장 상승비율 하한', 'type': 'float', 'min': 0.0, 'max': 1.0, 'step': 0.01, 'group': '애널·시장'},
|
||||
{'key': 'MAX_POSITIONS', 'label': '동시 보유 최대 종목', 'type': 'int', 'min': 1, 'max': 20, 'step': 1, 'group': '포지션'},
|
||||
]
|
||||
|
||||
# 오버라이드 적용 전 원본 기본값 캡처 (튜닝 화면의 '기본값' 표시·복원용)
|
||||
_DEFAULTS = {spec['key']: globals()[spec['key']] for spec in TUNABLE}
|
||||
|
||||
|
||||
def _coerce(spec: dict, v):
|
||||
"""문자열/숫자를 spec 타입으로 변환 + min/max clamp. 실패 시 None."""
|
||||
try:
|
||||
v = int(float(v)) if spec['type'] == 'int' else float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return max(spec['min'], min(spec['max'], v))
|
||||
|
||||
|
||||
def load_params() -> dict:
|
||||
"""현재 유효 파라미터 {key: value} — 저장값(clamp) 우선, 없으면 기본값."""
|
||||
try:
|
||||
saved = json.loads(PARAMS_PATH.read_text())
|
||||
except Exception:
|
||||
saved = {}
|
||||
out = {}
|
||||
for spec in TUNABLE:
|
||||
k = spec['key']
|
||||
cv = _coerce(spec, saved[k]) if k in saved else None
|
||||
out[k] = cv if cv is not None else _DEFAULTS[k]
|
||||
return out
|
||||
|
||||
|
||||
def save_params(raw: dict) -> dict:
|
||||
"""raw(폼 문자열 dict) 검증·clamp 후 params.json 병합 저장."""
|
||||
try:
|
||||
cur = json.loads(PARAMS_PATH.read_text())
|
||||
except Exception:
|
||||
cur = {}
|
||||
for spec in TUNABLE:
|
||||
k = spec['key']
|
||||
if k in raw and str(raw[k]).strip() != '':
|
||||
cv = _coerce(spec, raw[k])
|
||||
if cv is not None:
|
||||
cur[k] = cv
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PARAMS_PATH.write_text(json.dumps(cur, ensure_ascii=False, indent=2))
|
||||
return cur
|
||||
|
||||
|
||||
def reset_params():
|
||||
"""저장된 오버라이드 삭제 → 전부 기본값."""
|
||||
if PARAMS_PATH.exists():
|
||||
PARAMS_PATH.unlink()
|
||||
|
||||
|
||||
def _apply_overrides():
|
||||
"""import 시 유효 파라미터를 모듈 전역에 반영 (엔진·신호가 config.X 로 참조)."""
|
||||
g = globals()
|
||||
for k, v in load_params().items():
|
||||
g[k] = v
|
||||
|
||||
|
||||
_apply_overrides()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""종목별 데이터 수집 — 키움 시세·일봉·수급 + FnGuide/WISEreport 애널.
|
||||
|
||||
비용 절약:
|
||||
- 시세: get_watchlist_quotes 로 universe 전체 1콜 batch.
|
||||
- 일봉: daily_candles_cache (어제까지 캐시 재활용).
|
||||
- 수급: ka10059, state/sim/flow_cache/{code}.json 에 60분 TTL 캐시 (5일 net 은 장중 거의 불변).
|
||||
- 애널: fnguide/wisereport 클라이언트 자체 디스크 캐시(12h) 사용.
|
||||
- 호가: 체결 직전 종목만 on-demand.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
from . import config
|
||||
|
||||
sys.path.insert(0, str(config.SCRIPTS))
|
||||
import kiwoom_client as kc # noqa: E402
|
||||
import daily_candles_cache as dcc # noqa: E402
|
||||
|
||||
FLOW_CACHE_DIR = config.STATE_DIR / 'flow_cache'
|
||||
FLOW_TTL_SEC = 3600
|
||||
|
||||
# 애널 크롤링 페이싱 — 캐시 미스(실제 네트워크)일 때만 종목 간 간격을 둬 fnguide/wisereport 연속요청 차단 회피.
|
||||
FNGUIDE_CACHE_DIR = config.WORKSPACE / 'state' / 'fnguide_cache'
|
||||
WISEREPORT_CACHE_DIR = config.WORKSPACE / 'state' / 'wisereport_cache'
|
||||
ANALYST_CACHE_TTL = 12 * 3600 # fnguide/wisereport 클라이언트 TTL과 동일
|
||||
ANALYST_PACING_SEC = 0.4
|
||||
|
||||
|
||||
def _cache_fresh(path, ttl: int = ANALYST_CACHE_TTL) -> bool:
|
||||
"""캐시 파일 mtime이 TTL 이내면 True(히트 예상). 없으면 False(곧 네트워크)."""
|
||||
try:
|
||||
return (time.time() - path.stat().st_mtime) < ttl
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def batch_quotes(codes: list[str]) -> dict[str, dict]:
|
||||
"""universe 전체 오늘 시세 1콜 (가격·시고저·거래량)."""
|
||||
if not codes:
|
||||
return {}
|
||||
try:
|
||||
return kc.get_watchlist_quotes(codes)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[data] batch_quotes 실패: {e}\n')
|
||||
return {}
|
||||
|
||||
|
||||
MIN_BARS = config.SMA_LONG + 5 # 지표 계산 최소 봉수
|
||||
|
||||
|
||||
def candles(code: str, count: int = 80) -> list[dict]:
|
||||
"""어제까지 일봉 (오름차순).
|
||||
|
||||
캐시 우선 — 충분히 쌓여 있으면 sqlite 직접 읽기(네트워크 X)로 ka10081 rate limit 회피.
|
||||
캐시가 부족한 종목만 1회 네트워크 fetch(캐시 warm). 약간 stale 해도 SMA/ATR엔 무방하며
|
||||
오늘 봉은 engine 이 라이브 시세로 따로 결합한다.
|
||||
"""
|
||||
cached_desc = dcc._select_latest(code, count)
|
||||
if len(cached_desc) >= MIN_BARS:
|
||||
return list(reversed(cached_desc))
|
||||
try:
|
||||
return dcc.get_candles(code, count=count)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[data] candles {code} 실패: {e}\n')
|
||||
return list(reversed(cached_desc))
|
||||
|
||||
|
||||
def build_series(hist: list[dict], today_quote: dict | None) -> list[dict]:
|
||||
"""어제까지 일봉 + 오늘 라이브 봉 결합. 오늘 시세 없으면 hist 그대로."""
|
||||
if not today_quote or not today_quote.get('price'):
|
||||
return hist
|
||||
today_bar = {
|
||||
'date': 'TODAY',
|
||||
'open': today_quote.get('open') or today_quote['price'],
|
||||
'high': today_quote.get('high') or today_quote['price'],
|
||||
'low': today_quote.get('low') or today_quote['price'],
|
||||
'close': today_quote['price'],
|
||||
'volume': today_quote.get('volume') or 0,
|
||||
}
|
||||
return hist + [today_bar]
|
||||
|
||||
|
||||
def _flow_cache_path(code: str):
|
||||
return FLOW_CACHE_DIR / f'{code}.json'
|
||||
|
||||
|
||||
def investor_flow(code: str, days: int = config.FLOW_DAYS) -> list[dict]:
|
||||
"""최근 days 일 외국인·기관·개인 순매수 (최신순). 60분 TTL 캐시."""
|
||||
path = _flow_cache_path(code)
|
||||
now = time.time()
|
||||
if path.exists():
|
||||
try:
|
||||
cached = json.loads(path.read_text())
|
||||
if now - cached.get('ts', 0) < FLOW_TTL_SEC:
|
||||
return cached.get('rows', [])[:days]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
rows = kc.get_investor_flow(code, days=max(days, config.FLOW_DAYS))
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[data] investor_flow {code} 실패: {e}\n')
|
||||
return []
|
||||
FLOW_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({'ts': now, 'rows': rows}, ensure_ascii=False))
|
||||
return rows[:days]
|
||||
|
||||
|
||||
def flow_net(rows: list[dict]) -> dict:
|
||||
"""수급 행 → 외국인·기관 누적 순매수(천주). 양수=순매수."""
|
||||
foreign = sum(r.get('foreign', 0) for r in rows)
|
||||
institution = sum(r.get('institution', 0) for r in rows)
|
||||
return {'foreign': foreign, 'institution': institution, 'days': len(rows)}
|
||||
|
||||
|
||||
def analyst(code: str, price: int) -> dict | None:
|
||||
"""애널 게이트/가점용. 캐시 미스(네트워크 발생) 시에만 페이싱 — 연속 크롤링 차단 회피."""
|
||||
fresh = (_cache_fresh(FNGUIDE_CACHE_DIR / f'{code}.json')
|
||||
and _cache_fresh(WISEREPORT_CACHE_DIR / f'{code}.json'))
|
||||
try:
|
||||
return _analyst_query(code, price)
|
||||
finally:
|
||||
if not fresh:
|
||||
time.sleep(ANALYST_PACING_SEC)
|
||||
|
||||
|
||||
def _analyst_query(code: str, price: int) -> dict | None:
|
||||
"""target_price·opinion·upside·revision·surprise. ETF·실패 시 None."""
|
||||
target_price = opinion = None
|
||||
try:
|
||||
import fnguide_client as fg
|
||||
f = fg.get_fundamentals(code)
|
||||
cons = (f or {}).get('consensus') or {}
|
||||
target_price = cons.get('target_price')
|
||||
opinion = cons.get('opinion')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
revision_up = surprise_pos = None
|
||||
try:
|
||||
import wisereport_client as wr
|
||||
c = wr.get_consensus(code)
|
||||
if c:
|
||||
rev = c.get('revision') or {}
|
||||
if rev.get('target_change_pct') is not None:
|
||||
revision_up = rev['target_change_pct'] > 0
|
||||
if not target_price and rev.get('target_last'):
|
||||
target_price = rev['target_last']
|
||||
surp = c.get('surprise') or {}
|
||||
items = surp.get('items') if isinstance(surp, dict) else None
|
||||
op_sp = (items or {}).get('영업이익', {}).get('fy0_surprise_pct') if items else None
|
||||
if op_sp is not None:
|
||||
surprise_pos = op_sp > 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not target_price and opinion is None and revision_up is None:
|
||||
return None
|
||||
upside = ((target_price / price - 1) * 100) if (target_price and price) else None
|
||||
return {
|
||||
'target_price': target_price,
|
||||
'opinion': opinion,
|
||||
'upside_pct': upside,
|
||||
'revision_up': revision_up,
|
||||
'surprise_pos': surprise_pos,
|
||||
}
|
||||
|
||||
|
||||
def quote_book(code: str) -> dict | None:
|
||||
"""체결가용 호가 (매도1/매수1). 실패 시 None."""
|
||||
try:
|
||||
return kc.get_quote_book(code)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[data] quote_book {code} 실패: {e}\n')
|
||||
return None
|
||||
@@ -0,0 +1,294 @@
|
||||
"""스캔 엔진 — 15분마다 universe 를 훑어 가상 매수/매도하고 판단 스냅샷을 남긴다.
|
||||
|
||||
흐름: universe → 배치 시세 → (보유=청산판단 / 미보유=추세스크린→수급·애널→매수판단)
|
||||
→ 가상 체결 → portfolio 저장 → last_scan.json (대시보드용) 기록.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from . import config, data, signals, universe
|
||||
from .portfolio import Portfolio
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def market_ok_map() -> dict[str, bool]:
|
||||
"""시장별 신규매수 허용 여부 — 당일 상승비율 기반 소프트 필터 (EOD 데이터라 보수적)."""
|
||||
latest = _load_jsonl_last_per_market(config.MARKET_HISTORY)
|
||||
out: dict[str, bool] = {}
|
||||
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
|
||||
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
|
||||
return out
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _gather(extra_codes=None):
|
||||
"""파라미터 무관한 라이브 데이터 1회 수집 (메인·변이 공유). 종목·시세·시장매핑.
|
||||
|
||||
extra_codes: universe에 없지만 청산 판단을 위해 포함해야 할 보유 코드(수동 삭제된 보유분).
|
||||
"""
|
||||
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)
|
||||
quotes = data.batch_quotes(codes)
|
||||
cmkt = universe.code_market_map()
|
||||
return uni, quotes, cmkt
|
||||
|
||||
|
||||
def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
"""주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X)."""
|
||||
mkt_ok = market_ok_map() # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산
|
||||
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 = data.candles(code, 80)
|
||||
series = data.build_series(hist, quote)
|
||||
ind = signals.compute_indicators(series)
|
||||
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)
|
||||
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 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':
|
||||
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'])
|
||||
pf.positions[code]['stop'] = 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 not signals.trend_ok(ind):
|
||||
decisions.append({
|
||||
'code': code, 'name': name, 'sources': sources, 'price': cur_price,
|
||||
'state': 'SKIP', 'reason': '추세 미충족', 'action': None,
|
||||
'checks': [
|
||||
{'label': '추세(20일선 위)', 'ok': cur_price > ind['sma_long'],
|
||||
'detail': f"{cur_price:,} vs {ind['sma_long']:,.0f}"},
|
||||
{'label': '정배열(5>20)', 'ok': ind['sma_short'] > ind['sma_long'],
|
||||
'detail': f"{ind['sma_short']:,.0f} / {ind['sma_long']:,.0f}"},
|
||||
],
|
||||
})
|
||||
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)
|
||||
dec = signals.evaluate_candidate(code, name, sources, ind, flow, anl, m_ok)
|
||||
|
||||
if 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)
|
||||
|
||||
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 _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에서 빠져도 청산 위해 포함
|
||||
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)
|
||||
|
||||
# 메인 (현재 params.json 튜닝)
|
||||
backtest.apply_params(main_params)
|
||||
snap = _run_scan(pf, uni, quotes, cmkt, now)
|
||||
_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 = [{'id': 'main', 'name': '메인 (현재 튜닝)', 'params': main_params,
|
||||
'summary': pf.summary(), 'benchmark': _alpha(pf)}]
|
||||
for v in vdefs:
|
||||
backtest.apply_params(v.get('params') or {})
|
||||
vpf = vpfs[v['id']]
|
||||
_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)})
|
||||
|
||||
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))
|
||||
return snap
|
||||
|
||||
|
||||
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','')}")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""기술지표 — 순수 함수. 일봉 리스트(시간 오름차순)나 종가 리스트를 받아 계산만 한다.
|
||||
|
||||
입력 candle dict 키: open/high/low/close/volume (daily_candles_cache.get_candles 형식).
|
||||
모든 함수는 데이터 부족 시 None 을 반환한다 (raise X).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def sma(values: list[float], period: int) -> float | None:
|
||||
"""최근 period 개 단순이동평균."""
|
||||
if period <= 0 or len(values) < period:
|
||||
return None
|
||||
return sum(values[-period:]) / period
|
||||
|
||||
|
||||
def rsi(closes: list[float], period: int = 14) -> float | None:
|
||||
"""Wilder RSI. closes 오름차순, period+1 개 이상 필요."""
|
||||
if len(closes) < period + 1:
|
||||
return None
|
||||
gains = losses = 0.0
|
||||
for i in range(1, period + 1):
|
||||
diff = closes[i] - closes[i - 1]
|
||||
if diff >= 0:
|
||||
gains += diff
|
||||
else:
|
||||
losses -= diff
|
||||
avg_gain = gains / period
|
||||
avg_loss = losses / period
|
||||
for i in range(period + 1, len(closes)):
|
||||
diff = closes[i] - closes[i - 1]
|
||||
gain = diff if diff > 0 else 0.0
|
||||
loss = -diff if diff < 0 else 0.0
|
||||
avg_gain = (avg_gain * (period - 1) + gain) / period
|
||||
avg_loss = (avg_loss * (period - 1) + loss) / period
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
rs = avg_gain / avg_loss
|
||||
return 100.0 - (100.0 / (1.0 + rs))
|
||||
|
||||
|
||||
def atr(candles: list[dict], period: int = 14) -> float | None:
|
||||
"""Average True Range (Wilder). candles 오름차순, period+1 개 이상 필요."""
|
||||
if len(candles) < period + 1:
|
||||
return None
|
||||
trs: list[float] = []
|
||||
for i in range(1, len(candles)):
|
||||
h = candles[i]['high']
|
||||
lo = candles[i]['low']
|
||||
prev_close = candles[i - 1]['close']
|
||||
trs.append(max(h - lo, abs(h - prev_close), abs(lo - prev_close)))
|
||||
if len(trs) < period:
|
||||
return None
|
||||
atr_val = sum(trs[:period]) / period
|
||||
for tr in trs[period:]:
|
||||
atr_val = (atr_val * (period - 1) + tr) / period
|
||||
return atr_val
|
||||
|
||||
|
||||
def recent_high(candles: list[dict], period: int, exclude_last: bool = True) -> float | None:
|
||||
"""최근 period 봉 최고가. exclude_last 면 마지막(오늘) 봉 제외 → 돌파 판정용."""
|
||||
series = candles[:-1] if exclude_last else candles
|
||||
window = series[-period:]
|
||||
if not window:
|
||||
return None
|
||||
return max(c['high'] for c in window)
|
||||
|
||||
|
||||
def recent_low(candles: list[dict], period: int, exclude_last: bool = True) -> float | None:
|
||||
"""최근 period 봉 최저가 — 손절선 후보."""
|
||||
series = candles[:-1] if exclude_last else candles
|
||||
window = series[-period:]
|
||||
if not window:
|
||||
return None
|
||||
return min(c['low'] for c in window)
|
||||
|
||||
|
||||
def volume_avg(candles: list[dict], period: int, exclude_last: bool = True) -> float | None:
|
||||
"""최근 period 봉 평균 거래량. exclude_last 면 오늘 제외."""
|
||||
series = candles[:-1] if exclude_last else candles
|
||||
window = series[-period:]
|
||||
if len(window) < period:
|
||||
return None
|
||||
return sum(c['volume'] for c in window) / period
|
||||
@@ -0,0 +1,208 @@
|
||||
"""가상 포트폴리오 — 현금·포지션·체결·비용·손익·MDD.
|
||||
|
||||
상태: state/sim/portfolio.json
|
||||
거래로그: state/sim/trades.jsonl (append)
|
||||
실제 주문 X — 전부 장부상 가상 체결.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from . import config
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(config.KST).isoformat()
|
||||
|
||||
|
||||
class Portfolio:
|
||||
def __init__(self, data: dict):
|
||||
self.cash: float = data.get('cash', config.INITIAL_CAPITAL)
|
||||
self.initial: float = data.get('initial', config.INITIAL_CAPITAL)
|
||||
self.positions: dict[str, dict] = data.get('positions', {})
|
||||
self.realized_pnl: float = data.get('realized_pnl', 0.0)
|
||||
self.closed_trades: int = data.get('closed_trades', 0)
|
||||
self.wins: int = data.get('wins', 0)
|
||||
self.peak_equity: float = data.get('peak_equity', self.initial)
|
||||
self.max_drawdown: float = data.get('max_drawdown', 0.0)
|
||||
self.created_at: str = data.get('created_at', _now_iso())
|
||||
self.updated_at: str = data.get('updated_at', self.created_at)
|
||||
# 영속화 경로 (변이는 별도 경로, 기본은 메인 sim)
|
||||
self._pf_path = config.PORTFOLIO_PATH
|
||||
self._trades_path = config.TRADES_PATH
|
||||
|
||||
# ---- 영속화 ----
|
||||
@classmethod
|
||||
def load(cls, portfolio_path=None, trades_path=None) -> 'Portfolio':
|
||||
pp = portfolio_path or config.PORTFOLIO_PATH
|
||||
tp = trades_path or config.TRADES_PATH
|
||||
data = {}
|
||||
if pp.exists():
|
||||
try:
|
||||
data = json.loads(pp.read_text())
|
||||
except Exception:
|
||||
data = {}
|
||||
obj = cls(data)
|
||||
obj._pf_path = pp
|
||||
obj._trades_path = tp
|
||||
return obj
|
||||
|
||||
def save(self):
|
||||
self._pf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.updated_at = _now_iso()
|
||||
self._pf_path.write_text(json.dumps({
|
||||
'cash': self.cash, 'initial': self.initial, 'positions': self.positions,
|
||||
'realized_pnl': self.realized_pnl, 'closed_trades': self.closed_trades,
|
||||
'wins': self.wins, 'peak_equity': self.peak_equity,
|
||||
'max_drawdown': self.max_drawdown,
|
||||
'created_at': self.created_at, 'updated_at': self.updated_at,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
|
||||
def _log_trade(self, rec: dict):
|
||||
self._trades_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._trades_path.open('a') as f:
|
||||
f.write(json.dumps(rec, ensure_ascii=False) + '\n')
|
||||
|
||||
# ---- 체결 ----
|
||||
def can_open(self) -> bool:
|
||||
return len(self.positions) < config.MAX_POSITIONS
|
||||
|
||||
def target_position_value(self) -> float:
|
||||
return self.equity_estimate() / config.MAX_POSITIONS
|
||||
|
||||
def equity_estimate(self) -> float:
|
||||
"""현재 포지션의 진입가 기준 추정 자산 (사이징용 — mark 전 호출 대비)."""
|
||||
held = sum(p['qty'] * p.get('cur_price', p['entry_price']) for p in self.positions.values())
|
||||
return self.cash + held
|
||||
|
||||
def _qty_for(self, fill_price: float, value: float) -> int:
|
||||
return int(value // (fill_price * (1 + config.COMMISSION_RATE)))
|
||||
|
||||
def buy(self, code, name, fill_price, sources, stop, target, path, reason, signals=None) -> dict | None:
|
||||
"""신규 진입(1차 트랜치). 목표비중을 ENTRY_TRANCHES 로 나눈 만큼만 매수. 체결 dict 또는 None."""
|
||||
if code in self.positions or not self.can_open():
|
||||
return None
|
||||
full_target = self.target_position_value()
|
||||
tranche_val = full_target / max(1, config.ENTRY_TRANCHES)
|
||||
qty = self._qty_for(fill_price, tranche_val)
|
||||
if qty < 1:
|
||||
return None
|
||||
cost = qty * fill_price * (1 + config.COMMISSION_RATE)
|
||||
if cost > self.cash:
|
||||
qty = self._qty_for(fill_price, self.cash)
|
||||
if qty < 1:
|
||||
return None
|
||||
cost = qty * fill_price * (1 + config.COMMISSION_RATE)
|
||||
self.cash -= cost
|
||||
self.positions[code] = {
|
||||
'code': code, 'name': name, 'sources': sources,
|
||||
'qty': qty, 'entry_price': fill_price, 'entry_at': _now_iso(),
|
||||
'stop': stop, 'target': target, 'peak': fill_price,
|
||||
'trailing_on': False, 'scaled_out': False, 'entry_path': path,
|
||||
'entry_reason': reason, 'cur_price': fill_price,
|
||||
'tranches': 1, 'tranche_value': tranche_val, 'last_add_price': fill_price,
|
||||
}
|
||||
rec = {'ts': _now_iso(), 'side': 'BUY', 'code': code, 'name': name,
|
||||
'price': fill_price, 'qty': qty, 'cost': round(cost),
|
||||
'path': path, 'reason': reason, 'stop': stop, 'target': target,
|
||||
'signals': signals or []}
|
||||
self._log_trade(rec)
|
||||
return rec
|
||||
|
||||
def add_tranche(self, code, fill_price, reason, signals=None) -> dict | None:
|
||||
"""추격매수 — 보유 종목에 다음 트랜치 추가. 평단·수량 갱신. 체결 dict 또는 None."""
|
||||
pos = self.positions.get(code)
|
||||
if not pos or pos.get('tranches', 1) >= config.ENTRY_TRANCHES:
|
||||
return None
|
||||
tranche_val = pos.get('tranche_value') or (pos['entry_price'] * pos['qty'])
|
||||
qty = self._qty_for(fill_price, tranche_val)
|
||||
if qty < 1:
|
||||
return None
|
||||
cost = qty * fill_price * (1 + config.COMMISSION_RATE)
|
||||
if cost > self.cash:
|
||||
qty = self._qty_for(fill_price, self.cash)
|
||||
if qty < 1:
|
||||
return None
|
||||
cost = qty * fill_price * (1 + config.COMMISSION_RATE)
|
||||
new_qty = pos['qty'] + qty
|
||||
pos['entry_price'] = (pos['entry_price'] * pos['qty'] + fill_price * qty) / new_qty
|
||||
pos['qty'] = new_qty
|
||||
pos['tranches'] = pos.get('tranches', 1) + 1
|
||||
pos['last_add_price'] = fill_price
|
||||
pos['cur_price'] = fill_price
|
||||
self.cash -= cost
|
||||
rec = {'ts': _now_iso(), 'side': 'BUY', 'code': code, 'name': pos['name'],
|
||||
'price': fill_price, 'qty': qty, 'cost': round(cost),
|
||||
'path': 'add', 'reason': reason, 'stop': pos['stop'], 'target': pos['target'],
|
||||
'signals': signals or []}
|
||||
self._log_trade(rec)
|
||||
return rec
|
||||
|
||||
def sell(self, code, fill_price, reason, signals=None, frac: float = 1.0) -> dict | None:
|
||||
"""매도. frac<1 이면 부분(분할)매도 — 포지션 유지하고 수량만 차감."""
|
||||
pos = self.positions.get(code)
|
||||
if not pos:
|
||||
return None
|
||||
total = pos['qty']
|
||||
qty = total if frac >= 1.0 else max(1, int(total * frac))
|
||||
if qty >= total:
|
||||
qty = total
|
||||
entry = pos['entry_price']
|
||||
proceeds = qty * fill_price * (1 - config.COMMISSION_RATE - config.SELL_TAX_RATE)
|
||||
entry_cost = qty * entry * (1 + config.COMMISSION_RATE)
|
||||
pnl = proceeds - entry_cost
|
||||
self.cash += proceeds
|
||||
self.realized_pnl += pnl
|
||||
partial = qty < total
|
||||
rec = {'ts': _now_iso(), 'side': 'SELL', 'code': code, 'name': pos['name'],
|
||||
'price': fill_price, 'qty': qty, 'proceeds': round(proceeds),
|
||||
'entry_price': round(entry), 'pnl': round(pnl),
|
||||
'pnl_pct': round((fill_price / entry - 1) * 100, 2),
|
||||
'hold_from': pos['entry_at'], 'reason': reason, 'partial': partial,
|
||||
'signals': signals or []}
|
||||
self._log_trade(rec)
|
||||
if partial:
|
||||
pos['qty'] = total - qty
|
||||
else:
|
||||
self.closed_trades += 1
|
||||
if pnl > 0:
|
||||
self.wins += 1
|
||||
del self.positions[code]
|
||||
return rec
|
||||
|
||||
def apply_position_update(self, code: str, upd: dict):
|
||||
if code in self.positions:
|
||||
self.positions[code].update(upd)
|
||||
|
||||
def mark(self, code: str, cur_price: int):
|
||||
if code in self.positions:
|
||||
self.positions[code]['cur_price'] = cur_price
|
||||
|
||||
# ---- 지표 ----
|
||||
def update_equity_metrics(self):
|
||||
eq = self.equity_estimate()
|
||||
if eq > self.peak_equity:
|
||||
self.peak_equity = eq
|
||||
dd = (self.peak_equity - eq) / self.peak_equity if self.peak_equity else 0.0
|
||||
if dd > self.max_drawdown:
|
||||
self.max_drawdown = dd
|
||||
return eq
|
||||
|
||||
def summary(self) -> dict:
|
||||
eq = self.equity_estimate()
|
||||
held_val = eq - self.cash
|
||||
return {
|
||||
'initial': self.initial,
|
||||
'cash': round(self.cash),
|
||||
'held_value': round(held_val),
|
||||
'equity': round(eq),
|
||||
'total_return_pct': round((eq / self.initial - 1) * 100, 2),
|
||||
'realized_pnl': round(self.realized_pnl),
|
||||
'closed_trades': self.closed_trades,
|
||||
'wins': self.wins,
|
||||
'win_rate_pct': round(self.wins / self.closed_trades * 100, 1) if self.closed_trades else None,
|
||||
'open_positions': len(self.positions),
|
||||
'max_drawdown_pct': round(self.max_drawdown * 100, 2),
|
||||
'updated_at': self.updated_at,
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"""매수/매도 판단 — 결정론적 규칙. LLM 미경유.
|
||||
|
||||
방향(애널·펀더) 약한 게이트 + 가점 → 타이밍(수급·기술)으로 진입/청산.
|
||||
각 판단은 dashboard 용 체크리스트(checks)를 함께 반환한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from . import config, indicators as ind_mod
|
||||
|
||||
|
||||
def compute_indicators(series: list[dict]) -> dict | None:
|
||||
"""series(어제까지+오늘) → 지표 묶음. 데이터 부족 시 None."""
|
||||
closes = [c['close'] for c in series]
|
||||
sma_long = ind_mod.sma(closes, config.SMA_LONG)
|
||||
sma_short = ind_mod.sma(closes, config.SMA_SHORT)
|
||||
atr = ind_mod.atr(series, config.ATR_PERIOD)
|
||||
if sma_long is None or sma_short is None or atr is None:
|
||||
return None
|
||||
|
||||
# 회전율 — 오늘 봉(라이브 시세)엔 turnover_rate 없음 → 최근 봉의 거래량/회전율로 유통주식 추정해 환산
|
||||
hist = series[:-1]
|
||||
turns = [c['turnover_rate'] for c in hist[-config.VOLUME_AVG_PERIOD:]
|
||||
if c.get('turnover_rate')]
|
||||
turnover_avg = sum(turns) / len(turns) if turns else None
|
||||
est_float = None
|
||||
for c in reversed(hist):
|
||||
tr, v = c.get('turnover_rate'), c.get('volume')
|
||||
if tr and v:
|
||||
est_float = v / (tr / 100.0)
|
||||
break
|
||||
turnover_today = (series[-1]['volume'] / est_float * 100.0) if est_float else None
|
||||
|
||||
return {
|
||||
'price': closes[-1],
|
||||
'prev_close': closes[-2] if len(closes) >= 2 else closes[-1],
|
||||
'today_volume': series[-1]['volume'],
|
||||
'sma_short': sma_short,
|
||||
'sma_long': sma_long,
|
||||
'atr': atr,
|
||||
'recent_high': ind_mod.recent_high(series, config.BREAKOUT_LOOKBACK),
|
||||
'recent_low': ind_mod.recent_low(series, config.SWING_LOW_LOOKBACK),
|
||||
'volume_avg': ind_mod.volume_avg(series, config.VOLUME_AVG_PERIOD),
|
||||
'turnover_today': turnover_today,
|
||||
'turnover_avg': turnover_avg,
|
||||
}
|
||||
|
||||
|
||||
def trend_ok(ind: dict) -> bool:
|
||||
"""추세 1차 스크린 — 비싼 수급·애널 호출 전 게이트."""
|
||||
return ind['price'] > ind['sma_long'] and ind['sma_short'] > ind['sma_long']
|
||||
|
||||
|
||||
def compute_stop_target(entry: float, atr: float, recent_low: float | None) -> tuple[int, int]:
|
||||
"""손절 = max(진입−ATR×2, 스윙저점) → 둘 중 위쪽(덜 손해). 목표 = 진입 + (진입−손절)×RR."""
|
||||
atr_stop = entry - atr * config.STOP_ATR_MULT
|
||||
stop = max(atr_stop, recent_low) if recent_low else atr_stop
|
||||
stop = min(stop, entry - 1) # 손절은 진입가보다 아래여야 함
|
||||
target = entry + (entry - stop) * config.RR_RATIO
|
||||
return int(round(stop)), int(round(target))
|
||||
|
||||
|
||||
def _analyst_checks(analyst: dict | None, price: float) -> tuple[bool, list[dict], int]:
|
||||
"""애널 약한 게이트 + 가점. 반환: (게이트 통과, checks, 가점)."""
|
||||
checks: list[dict] = []
|
||||
bonus = 0
|
||||
if not analyst:
|
||||
checks.append({'label': '애널', 'ok': None, 'detail': '데이터 없음(통과)'})
|
||||
return True, checks, 0
|
||||
|
||||
gate_ok = True
|
||||
op = analyst.get('opinion')
|
||||
if op is not None:
|
||||
sell = op <= config.ANALYST_SELL_OPINION_MAX
|
||||
checks.append({'label': '투자의견', 'ok': not sell,
|
||||
'detail': f'{op:.1f}/5' + (' 매도권' if sell else '')})
|
||||
if sell:
|
||||
gate_ok = False
|
||||
up = analyst.get('upside_pct')
|
||||
if up is not None:
|
||||
ok = up >= config.ANALYST_MIN_UPSIDE_PCT
|
||||
checks.append({'label': '상승여력', 'ok': ok, 'detail': f'{up:+.0f}%'})
|
||||
if not ok:
|
||||
gate_ok = False
|
||||
if analyst.get('revision_up'):
|
||||
checks.append({'label': '목표가 리비전', 'ok': True, 'detail': '상향 ➕'})
|
||||
bonus += 1
|
||||
if analyst.get('surprise_pos'):
|
||||
checks.append({'label': '어닝 서프라이즈', 'ok': True, 'detail': '+ ➕'})
|
||||
bonus += 1
|
||||
return gate_ok, checks, bonus
|
||||
|
||||
|
||||
def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> dict:
|
||||
"""미보유 종목 매수 판단. flow/analyst 는 engine 이 trend 통과 후 주입."""
|
||||
price = ind['price']
|
||||
checks: list[dict] = []
|
||||
|
||||
# 1. 추세
|
||||
checks.append({'label': '추세(20일선 위)', 'ok': price > ind['sma_long'],
|
||||
'detail': f"{price:,} vs {ind['sma_long']:,.0f}"})
|
||||
checks.append({'label': '정배열(5>20)', 'ok': ind['sma_short'] > ind['sma_long'],
|
||||
'detail': f"{ind['sma_short']:,.0f} / {ind['sma_long']:,.0f}"})
|
||||
|
||||
# 2. 수급
|
||||
fnet = flow.get('foreign', 0) if flow else 0
|
||||
inet = flow.get('institution', 0) if flow else 0
|
||||
supply_ok = fnet > 0 or inet > 0
|
||||
checks.append({'label': f'수급({config.FLOW_DAYS}일 외/기)', 'ok': supply_ok,
|
||||
'detail': f'외 {fnet:+,} · 기 {inet:+,} (천주)'})
|
||||
|
||||
# 3. 애널 게이트+가점
|
||||
gate_ok, acks, bonus = _analyst_checks(analyst, price)
|
||||
checks.extend(acks)
|
||||
|
||||
base = {'code': code, 'name': name, 'sources': sources, 'price': price,
|
||||
'checks': checks, 'action': None, 'buy_path': None, 'buy_price': None,
|
||||
'plan_stop': None, 'plan_target': None, 'bonus': bonus}
|
||||
|
||||
if not gate_ok:
|
||||
return {**base, 'state': 'SKIP', 'reason': '애널 게이트 제외'}
|
||||
if not market_ok:
|
||||
return {**base, 'state': 'WAIT', 'reason': '시장 약세 — 신규매수 보류'}
|
||||
if not supply_ok:
|
||||
return {**base, 'state': 'WAIT', 'reason': '수급 미충족'}
|
||||
|
||||
# 4. 과열 보류 — 회전율이 평균 대비 과도하면 돌파·눌림목 불문 신규매수 보류
|
||||
t_today, t_avg = ind.get('turnover_today'), ind.get('turnover_avg')
|
||||
overheated = bool(t_today and t_avg and t_today >= t_avg * config.TURNOVER_OVERHEAT_MULT)
|
||||
if t_today is not None and t_avg:
|
||||
checks.append({'label': '과열 아님(회전율)', 'ok': not overheated,
|
||||
'detail': f"{t_today:.2f}% vs 평균 {t_avg:.2f}% (보류 {config.TURNOVER_OVERHEAT_MULT:.0f}배↑)"})
|
||||
if overheated:
|
||||
return {**base, 'state': 'WAIT',
|
||||
'reason': f'과열(회전율 {t_today:.1f}% ≥ 평균×{config.TURNOVER_OVERHEAT_MULT:.0f}) — 신규매수 보류'}
|
||||
|
||||
# 5. 매수 경로 — 돌파 우선 (전고점 + 거래량 급증 + 회전율 급증 모두 충족)
|
||||
price_break = bool(ind['recent_high'] and price > ind['recent_high'])
|
||||
vol_mult_ok = bool(ind['volume_avg'] and ind['today_volume'] >= ind['volume_avg'] * config.VOLUME_BREAKOUT_MULT)
|
||||
turn_ok = (t_today is None or not t_avg) or (t_today >= t_avg * config.TURNOVER_BREAKOUT_MULT)
|
||||
breakout = price_break and vol_mult_ok and turn_ok
|
||||
|
||||
checks.append({'label': '돌파(전고점)', 'ok': price_break,
|
||||
'detail': f"고점 {ind['recent_high']:,.0f}" if ind['recent_high'] else '—'})
|
||||
checks.append({'label': '거래량 급증', 'ok': vol_mult_ok,
|
||||
'detail': f"{ind['today_volume']:,} vs 평균 {ind['volume_avg']:,.0f}" if ind['volume_avg'] else '—'})
|
||||
if t_today is not None and t_avg:
|
||||
checks.append({'label': '회전율 급증', 'ok': bool(turn_ok),
|
||||
'detail': f"{t_today:.2f}% vs 평균 {t_avg:.2f}%"})
|
||||
else:
|
||||
checks.append({'label': '회전율', 'ok': None, 'detail': '데이터 없음(통과)'})
|
||||
|
||||
if breakout:
|
||||
return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'breakout',
|
||||
'buy_price': price, 'reason': '거래량·회전율 동반 전고점 돌파'}
|
||||
|
||||
# 6. 눌림목 지정가
|
||||
limit = max(ind['sma_long'], ind['prev_close'] - ind['atr'] * config.PULLBACK_ATR_MULT)
|
||||
limit = int(round(limit))
|
||||
pulled = price <= limit
|
||||
checks.append({'label': '눌림목 도달', 'ok': pulled,
|
||||
'detail': f'지정가 {limit:,} (현재 {price:,})'})
|
||||
if pulled:
|
||||
# 현재가가 이미 지정가 이하 → 시장에서 현재가(체결가)로 매수
|
||||
return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'pullback',
|
||||
'buy_price': price, 'watch_price': limit,
|
||||
'reason': f'눌림목 지정가 {limit:,} 도달'}
|
||||
|
||||
return {**base, 'state': 'WAIT', 'watch_price': limit,
|
||||
'reason': f'눌림목 대기 — 지정가 {limit:,}'}
|
||||
|
||||
|
||||
def evaluate_holding(position, ind, flow, analyst) -> dict:
|
||||
"""보유 종목 판단. 우선순위: 손절/추세이탈(전량) → 분할익절(scale_out) → 추격매수(add) → 유지.
|
||||
|
||||
position 의 stop/peak/trailing/scaled_out 갱신값도 position_update 로 함께 반환.
|
||||
"""
|
||||
price = ind['price']
|
||||
entry = position['entry_price']
|
||||
stop = position['stop']
|
||||
target = position['target']
|
||||
peak = max(position.get('peak', entry), price)
|
||||
trailing_on = position.get('trailing_on', False)
|
||||
scaled_out = position.get('scaled_out', False)
|
||||
tranches = position.get('tranches', 1)
|
||||
last_add = position.get('last_add_price', entry)
|
||||
|
||||
if trailing_on:
|
||||
atarget = (analyst or {}).get('target_price')
|
||||
mult = config.TRAIL_ATR_MULT_TIGHT if (atarget and price >= atarget) else config.TRAIL_ATR_MULT
|
||||
stop = max(stop, int(round(peak - ind['atr'] * mult)))
|
||||
|
||||
fnet = flow.get('foreign', 0) if flow else 0
|
||||
inet = flow.get('institution', 0) if flow else 0
|
||||
supply_ok = fnet > 0 or inet > 0
|
||||
trend_up = price > ind['sma_long'] and ind['sma_short'] > ind['sma_long']
|
||||
|
||||
add_trigger = int(round(last_add + ind['atr'] * config.ADD_ATR_MULT))
|
||||
can_add = tranches < config.ENTRY_TRANCHES and not trailing_on and not scaled_out
|
||||
|
||||
checks = [
|
||||
{'label': '손절선', 'ok': price > stop, 'detail': f'{stop:,} (현재 {price:,})'},
|
||||
{'label': '목표/트레일링', 'ok': None,
|
||||
'detail': ('트레일링 ON' if trailing_on else
|
||||
('일부익절 완료' if scaled_out else f'목표 {target:,}'))},
|
||||
{'label': '추세(20일선)', 'ok': price >= ind['sma_long'],
|
||||
'detail': f"{ind['sma_long']:,.0f}"},
|
||||
{'label': '수급(외/기)', 'ok': supply_ok, 'detail': f'외 {fnet:+,} · 기 {inet:+,}'},
|
||||
{'label': f'분할({tranches}/{config.ENTRY_TRANCHES})', 'ok': None,
|
||||
'detail': (f'추가 트리거 {add_trigger:,}' if can_add else '추가 종료')},
|
||||
]
|
||||
|
||||
upd = {'stop': stop, 'peak': peak, 'trailing_on': trailing_on, 'scaled_out': scaled_out}
|
||||
pnl_pct = (price / entry - 1) * 100
|
||||
|
||||
base = {'code': position['code'], 'name': position['name'], 'sources': position.get('sources', []),
|
||||
'price': price, 'checks': checks, 'action': None, 'state': 'HOLD',
|
||||
'position_update': upd, 'pnl_pct': pnl_pct}
|
||||
|
||||
# 1. 손절·추세이탈 — 전량
|
||||
if price <= stop:
|
||||
reason = '트레일링 익절' if (trailing_on and price > entry) else '손절'
|
||||
return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0, 'reason': reason}
|
||||
if price < ind['sma_long'] and fnet < 0 and inet < 0:
|
||||
return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0, 'reason': '추세·수급 이탈'}
|
||||
|
||||
# 2. 분할매도 — 목표 도달 & 미익절. 일부 익절 후 잔량은 트레일링으로 전환
|
||||
if target and price >= target and not scaled_out:
|
||||
upd['trailing_on'] = True
|
||||
if config.SCALE_OUT_FRAC > 0:
|
||||
upd['scaled_out'] = True
|
||||
return {**base, 'state': 'SELL', 'action': 'scale_out',
|
||||
'sell_frac': config.SCALE_OUT_FRAC,
|
||||
'reason': f'목표 도달 — {config.SCALE_OUT_FRAC * 100:.0f}% 분할익절'}
|
||||
return {**base, 'reason': '목표 도달 — 트레일링 전환'}
|
||||
|
||||
# 3. 추격매수 — 트랜치 여유 & 추세 지속 & 수급 & 직전 진입가 + ATR 돌파
|
||||
if can_add and trend_up and supply_ok and price >= add_trigger:
|
||||
return {**base, 'state': 'ADD', 'action': 'add',
|
||||
'reason': f'추격매수 — {tranches + 1}/{config.ENTRY_TRANCHES}차 (추세 지속)'}
|
||||
|
||||
return {**base, 'reason': '보유 유지'}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
"""파라미터 스윕 — 여러 튜닝을 백테스트로 동시 비교 → 순위표.
|
||||
|
||||
과최적화 방어: 기간을 앞(학습)/뒤(검증)로 쪼개 둘 다 측정.
|
||||
- 학습기간 성적만 높고 검증기간 무너지면 = 과최적화 의심.
|
||||
- 검증기간(out-of-sample) 성적으로 순위.
|
||||
|
||||
결과: state/sim/backtest_results.json (웹 백테스트 탭이 읽음).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from . import backtest, config, universe
|
||||
|
||||
# 백테스트에서 실제 영향 있는 파라미터만 스윕 (애널 게이트·시장필터는 backtest 중립이라 제외).
|
||||
# SMA_LONG 5단계로 추세 속도 다양화 + 돌파 강도 추가. 5×3×3×3×2 = 270 조합.
|
||||
GRID = {
|
||||
'SMA_LONG': [10, 20, 40, 60, 120], # 추세 속도 — 빠른/느린 (다양성 핵심)
|
||||
'RR_RATIO': [1.5, 2.0, 3.0], # 손익비
|
||||
'STOP_ATR_MULT': [1.5, 2.0, 3.0], # 손절 폭
|
||||
'PULLBACK_ATR_MULT': [0.5, 1.0, 1.5], # 눌림 깊이
|
||||
'VOLUME_BREAKOUT_MULT': [1.3, 2.0], # 돌파 거래량 강도
|
||||
}
|
||||
|
||||
MIN_TRADES = 10 # 검증기간 거래 이 미만이면 표본 부족 → 하위로
|
||||
|
||||
|
||||
def _all_dates(codes: list[str]) -> list[str]:
|
||||
s = set()
|
||||
for code in codes:
|
||||
for c in backtest.load_history(code):
|
||||
s.add(c['date'])
|
||||
return sorted(s)
|
||||
|
||||
|
||||
def run_sweep(codes: list[str] | None = None, train_frac: float = 0.7, grid: dict | None = None) -> dict:
|
||||
codes = codes or [e['code'] for e in universe.build_universe()]
|
||||
grid = grid or GRID
|
||||
dates = _all_dates(codes)
|
||||
if len(dates) < 60:
|
||||
return {'error': 'insufficient_history', 'days': len(dates)}
|
||||
split = int(len(dates) * train_frac)
|
||||
split_date = dates[split]
|
||||
train_to, test_from, test_to = dates[split - 1], dates[split], dates[-1]
|
||||
train_from = dates[0]
|
||||
|
||||
keys = list(grid.keys())
|
||||
combos = list(itertools.product(*[grid[k] for k in keys]))
|
||||
results = []
|
||||
for combo in combos:
|
||||
params = dict(zip(keys, combo))
|
||||
tr = backtest.run(params, codes, date_from=train_from, date_to=train_to)
|
||||
te = backtest.run(params, codes, date_from=test_from, date_to=test_to)
|
||||
results.append({'params': params, 'train': tr, 'test': te})
|
||||
|
||||
def sort_key(r):
|
||||
te = r['test']
|
||||
enough = (te.get('trades', 0) or 0) >= MIN_TRADES
|
||||
return (1 if enough else 0, te.get('total_return_pct', -999) or -999)
|
||||
|
||||
results.sort(key=sort_key, reverse=True)
|
||||
|
||||
out = {
|
||||
'generated_at': datetime.now(config.KST).isoformat(),
|
||||
'universe_size': len(codes),
|
||||
'swept_params': keys,
|
||||
'grid': grid,
|
||||
'split': {'train': f'{train_from}~{train_to}', 'test': f'{test_from}~{test_to}',
|
||||
'train_frac': train_frac},
|
||||
'min_trades': MIN_TRADES,
|
||||
'flow_used': results[0]['test'].get('flow_used', False) if results else False,
|
||||
'count': len(results),
|
||||
'results': results,
|
||||
}
|
||||
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(config.STATE_DIR / 'backtest_results.json').write_text(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
frac = float(sys.argv[1]) if len(sys.argv) > 1 else 0.7
|
||||
res = run_sweep(train_frac=frac)
|
||||
if res.get('error'):
|
||||
print('스윕 실패:', res)
|
||||
else:
|
||||
print(f"스윕 완료 — {res['count']}개 조합 · 검증기간 {res['split']['test']}")
|
||||
print(f"{'RR':>4} {'STOP':>5} {'SMA':>4} {'PULL':>5} | {'학습수익%':>8} {'검증수익%':>8} {'검증MDD%':>8} {'검증거래':>6} {'승률%':>6}")
|
||||
for r in res['results'][:12]:
|
||||
p, te, tr = r['params'], r['test'], r['train']
|
||||
print(f"{p['RR_RATIO']:>4} {p['STOP_ATR_MULT']:>5} {p['SMA_LONG']:>4} {p['PULLBACK_ATR_MULT']:>5} | "
|
||||
f"{tr['total_return_pct']:>8} {te['total_return_pct']:>8} {te['mdd_pct']:>8} "
|
||||
f"{te['trades']:>6} {str(te['win_rate_pct']):>6}")
|
||||
@@ -0,0 +1,165 @@
|
||||
"""종목 풀 조립 — sim 전용 누적 관찰목록(state/sim/watchlist.json).
|
||||
|
||||
자산 소스(비하이브 워치/관심 + 본인 보유)에 새 종목이 생기면 자동 편입하되,
|
||||
한 번 들어온 종목은 자동으로 빠지지 않는다(삭제는 수동만). 관심종목 탭에서 직접 추가/삭제 가능.
|
||||
|
||||
각 종목에 origin 태깅: watch / interest / held / manual. 가격선(비하이브 buy/target/stop)은
|
||||
의도적으로 무시한다 — 진입·손절·목표는 sim 엔진이 데이터로 직접 계산한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from . import config
|
||||
|
||||
sys.path.insert(0, str(config.SCRIPTS))
|
||||
import kiwoom_client as kc # noqa: E402
|
||||
|
||||
WATCHLIST_PATH = config.WORKSPACE / 'state' / 'behive_watchlist.json'
|
||||
INTERESTS_PATH = config.WORKSPACE / 'state' / 'behive_interests.json'
|
||||
SIM_WATCHLIST_PATH = config.STATE_DIR / 'watchlist.json' # sim 누적 관찰목록
|
||||
|
||||
|
||||
def _load_json(path, default):
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _load_watchlist() -> dict:
|
||||
"""sim 누적 관찰목록 {code: {name, origin, added_at}}."""
|
||||
wl = _load_json(SIM_WATCHLIST_PATH, {})
|
||||
return wl if isinstance(wl, dict) else {}
|
||||
|
||||
|
||||
def _save_watchlist(wl: dict):
|
||||
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SIM_WATCHLIST_PATH.write_text(json.dumps(wl, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def _collect_sources() -> dict[str, tuple[str, str]]:
|
||||
"""자산 소스(비하이브 워치/관심 + 본인 보유)에서 {code: (name, origin)} 수집. 코드 6자리 정규화."""
|
||||
out: dict[str, tuple[str, str]] = {}
|
||||
|
||||
def add(code: str, name: str, origin: str):
|
||||
code = (code or '').strip().zfill(6) if code else ''
|
||||
if not code or code in out:
|
||||
return
|
||||
out[code] = (name or '', origin)
|
||||
|
||||
watch = _load_json(WATCHLIST_PATH, {})
|
||||
for name, info in (watch.items() if isinstance(watch, dict) else []):
|
||||
add(info.get('code', ''), info.get('stock', name), 'watch')
|
||||
|
||||
interests = _load_json(INTERESTS_PATH, {})
|
||||
for name, info in (interests.items() if isinstance(interests, dict) else []):
|
||||
add(info.get('code', ''), info.get('stock', name), 'interest')
|
||||
|
||||
try:
|
||||
held = kc.get_positions_all(labels=config.OWNER_ACCOUNT_LABELS)
|
||||
for _label, positions in held.items():
|
||||
for p in positions:
|
||||
if p.get('qty', 0) > 0:
|
||||
add(p.get('code', ''), p.get('name', ''), 'held')
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[universe] 보유종목 조회 실패 (무시): {e}\n')
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def sync_watchlist() -> dict:
|
||||
"""자산 소스의 새 종목만 누적 관찰목록에 편입(이름 보강). 자동 삭제는 안 함. 갱신된 목록 반환."""
|
||||
wl = _load_watchlist()
|
||||
now = datetime.now(config.KST).isoformat()
|
||||
changed = False
|
||||
for code, (name, origin) in _collect_sources().items():
|
||||
ent = wl.get(code)
|
||||
if ent is None:
|
||||
wl[code] = {'name': name, 'origin': origin, 'added_at': now}
|
||||
changed = True
|
||||
elif not ent.get('name') and name: # 이름만 보강
|
||||
ent['name'] = name
|
||||
changed = True
|
||||
if changed:
|
||||
_save_watchlist(wl)
|
||||
return wl
|
||||
|
||||
|
||||
def search_stocks(query: str, limit: int = 20) -> list[dict]:
|
||||
"""종목명/코드 부분검색 → [{code, name}] 후보 (자동완성 팝업용). ci-exact > prefix > 부분 정렬."""
|
||||
q = (query or '').strip()
|
||||
if not q:
|
||||
return []
|
||||
cache = kc._load_code_cache()
|
||||
if q.isdigit() and len(q) == 6: # 6자리 코드 직접
|
||||
try:
|
||||
info = kc.resolve_stock_code(q)
|
||||
return [{'code': info.get('code', q), 'name': info.get('name', '')}]
|
||||
except Exception:
|
||||
return []
|
||||
ql = q.lower()
|
||||
hits = [(name, info) for name, info in cache.items() if ql in (name or '').lower()]
|
||||
|
||||
def sk(item):
|
||||
n = (item[0] or '').lower()
|
||||
rank = 0 if n == ql else 1 if n.startswith(ql) else 2
|
||||
return (rank, len(n), n) # 같은 등급이면 짧은 이름(핵심 종목) 우선 → '삼성전자'가 ETN보다 위
|
||||
|
||||
hits.sort(key=sk)
|
||||
return [{'code': info.get('code', ''), 'name': info.get('name') or name}
|
||||
for name, info in hits[:limit] if info.get('code')]
|
||||
|
||||
|
||||
def add_manual(code_or_name: str) -> dict | None:
|
||||
"""관심종목 탭 수동 추가 — 코드/이름 해석 후 origin='manual'로 편입. 이미 있으면 기존 반환."""
|
||||
try:
|
||||
info = kc.resolve_stock_code(code_or_name)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[universe] resolve 실패: {e}\n')
|
||||
return None
|
||||
code = (info.get('code') or '').strip().zfill(6)
|
||||
if not code:
|
||||
return None
|
||||
wl = _load_watchlist()
|
||||
if code not in wl:
|
||||
wl[code] = {'name': info.get('name') or '', 'origin': 'manual',
|
||||
'added_at': datetime.now(config.KST).isoformat()}
|
||||
_save_watchlist(wl)
|
||||
return {'code': code, **wl[code]}
|
||||
|
||||
|
||||
def remove(code: str):
|
||||
"""관심종목 탭 수동 삭제."""
|
||||
code = (code or '').strip().zfill(6)
|
||||
wl = _load_watchlist()
|
||||
if code in wl:
|
||||
del wl[code]
|
||||
_save_watchlist(wl)
|
||||
|
||||
|
||||
def build_universe() -> list[dict]:
|
||||
"""누적 관찰목록을 동기화 후 [{code, name, sources:[origin]}]로 반환. code 6자리 정규화."""
|
||||
wl = sync_watchlist()
|
||||
return [{'code': code, 'name': ent.get('name', ''), 'sources': [ent.get('origin', 'manual')]}
|
||||
for code, ent in wl.items()]
|
||||
|
||||
|
||||
def code_market_map() -> dict[str, str]:
|
||||
"""code → 'KOSPI'/'KOSDAQ' 매핑 (종목코드 캐시 기반). 모르면 빈값."""
|
||||
cache = kc._load_code_cache()
|
||||
out: dict[str, str] = {}
|
||||
for _name, meta in cache.items():
|
||||
code = (meta.get('code') or '').strip()
|
||||
if code:
|
||||
out[code.zfill(6)] = meta.get('market') or ''
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
u = build_universe()
|
||||
print(f'universe: {len(u)} 종목')
|
||||
for e in u:
|
||||
print(f" {e['code']} {e['name']:12s} {'/'.join(e['sources'])}")
|
||||
@@ -0,0 +1,109 @@
|
||||
"""병렬 페이퍼 변이(variant) 관리 — 각 변이는 {이름, 파라미터} + 자체 가상계좌.
|
||||
|
||||
정의: state/sim/variants.json = [{"id":"v1","name":"...","params":{KEY:VAL,...}}]
|
||||
계좌: state/sim/variants/<id>/{portfolio.json, trades.jsonl}
|
||||
비교: state/sim/variants_compare.json (웹 비교 탭)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from . import config
|
||||
|
||||
VARIANTS_PATH = config.STATE_DIR / 'variants.json'
|
||||
VARIANTS_DIR = config.STATE_DIR / 'variants'
|
||||
COMPARE_PATH = config.STATE_DIR / 'variants_compare.json'
|
||||
|
||||
|
||||
def load_variants() -> list[dict]:
|
||||
try:
|
||||
return json.loads(VARIANTS_PATH.read_text())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def save_variants(variants: list[dict]):
|
||||
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
VARIANTS_PATH.write_text(json.dumps(variants, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def variant_paths(vid: str):
|
||||
d = VARIANTS_DIR / vid
|
||||
return d / 'portfolio.json', d / 'trades.jsonl'
|
||||
|
||||
|
||||
def _label(params: dict) -> str:
|
||||
short = {'RR_RATIO': 'RR', 'STOP_ATR_MULT': 'S', 'SMA_LONG': 'SMA',
|
||||
'PULLBACK_ATR_MULT': 'P', 'VOLUME_BREAKOUT_MULT': 'V',
|
||||
'TURNOVER_OVERHEAT_MULT': 'OH'}
|
||||
return '·'.join(f'{short.get(k, k)}{v:g}' for k, v in params.items())
|
||||
|
||||
|
||||
def seed_from_sweep(n: int = 3) -> list[dict]:
|
||||
"""backtest_results.json 검증 상위 n개 → 변이로 등록 (기존 계좌는 유지)."""
|
||||
try:
|
||||
res = json.loads((config.STATE_DIR / 'backtest_results.json').read_text())
|
||||
except Exception:
|
||||
return []
|
||||
existing = {v['id']: v for v in load_variants()}
|
||||
variants = list(existing.values())
|
||||
seen_params = {json.dumps(v['params'], sort_keys=True) for v in variants}
|
||||
rank = 0
|
||||
for r in res.get('results', []):
|
||||
if rank >= n:
|
||||
break
|
||||
params = r['params']
|
||||
key = json.dumps(params, sort_keys=True)
|
||||
if key in seen_params:
|
||||
continue
|
||||
rank += 1
|
||||
vid = f'v{len(variants)+1}'
|
||||
variants.append({'id': vid, 'name': f'#{rank} {_label(params)}', 'params': params})
|
||||
seen_params.add(key)
|
||||
save_variants(variants)
|
||||
return variants
|
||||
|
||||
|
||||
def add_variant(params: dict) -> dict | None:
|
||||
"""단일 params 조합을 변이로 추가 (백테스트 행 → 비교군). 이미 있으면 None."""
|
||||
variants = load_variants()
|
||||
seen = {json.dumps(v['params'], sort_keys=True) for v in variants}
|
||||
if json.dumps(params, sort_keys=True) in seen:
|
||||
return None
|
||||
nums = [int(v['id'][1:]) for v in variants if v['id'][1:].isdigit()]
|
||||
vid = f'v{(max(nums) + 1) if nums else 1}'
|
||||
v = {'id': vid, 'name': _label(params), 'params': params}
|
||||
variants.append(v)
|
||||
save_variants(variants)
|
||||
return v
|
||||
|
||||
|
||||
def remove_variant(vid: str):
|
||||
"""변이 1개 삭제 (정의 + 계좌)."""
|
||||
import shutil
|
||||
save_variants([v for v in load_variants() if v['id'] != vid])
|
||||
d = VARIANTS_DIR / vid
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
|
||||
|
||||
def reset_variant(vid: str):
|
||||
"""변이 1개 성적만 초기화 (정의 유지 — 계좌 디렉터리 삭제 후 다음 스캔에 재생성)."""
|
||||
import shutil
|
||||
d = VARIANTS_DIR / vid
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
|
||||
|
||||
def reset_all():
|
||||
"""모든 변이 계좌 초기화 (variants.json 정의는 유지)."""
|
||||
import shutil
|
||||
if VARIANTS_DIR.exists():
|
||||
shutil.rmtree(VARIANTS_DIR)
|
||||
|
||||
|
||||
def clear():
|
||||
"""변이 정의·계좌 전부 삭제."""
|
||||
reset_all()
|
||||
if VARIANTS_PATH.exists():
|
||||
VARIANTS_PATH.unlink()
|
||||
@@ -20,3 +20,5 @@
|
||||
{"date": "2026-06-04", "market": "KOSPI", "market_label": "코스피", "rise": 447, "upper": 2, "fall": 446, "lower": 0, "steady": 31, "adr": 100.67, "personal": 50135, "foreign": -66663, "institutional": 15255, "captured_at": "2026-06-04T21:00:02.407555+09:00"}
|
||||
{"date": "2026-06-05", "market": "KOSDAQ", "market_label": "코스닥", "rise": 389, "upper": 4, "fall": 1298, "lower": 0, "steady": 49, "adr": 30.28, "personal": 337, "foreign": -1878, "institutional": 1508, "captured_at": "2026-06-05T21:00:05.007446+09:00"}
|
||||
{"date": "2026-06-05", "market": "KOSPI", "market_label": "코스피", "rise": 225, "upper": 1, "fall": 672, "lower": 0, "steady": 26, "adr": 33.63, "personal": 42240, "foreign": -27638, "institutional": -13809, "captured_at": "2026-06-05T21:00:05.007446+09:00"}
|
||||
{"date": "2026-06-08", "market": "KOSDAQ", "market_label": "코스닥", "rise": 75, "upper": 7, "fall": 1634, "lower": 1, "steady": 27, "adr": 5.02, "personal": -1245, "foreign": 2976, "institutional": -1466, "captured_at": "2026-06-08T21:00:04.897696+09:00"}
|
||||
{"date": "2026-06-08", "market": "KOSPI", "market_label": "코스피", "rise": 42, "upper": 1, "fall": 876, "lower": 0, "steady": 3, "adr": 4.91, "personal": 17628, "foreign": -2644, "institutional": -17164, "captured_at": "2026-06-08T21:00:04.897696+09:00"}
|
||||
|
||||
@@ -70,3 +70,9 @@
|
||||
{"date": "2026-06-05", "account": "ISA", "owner": "본인", "code": "017670", "name": "SK텔레콤", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 11, "sell_avg": 108300, "sell_amt": 1191300, "pl_amt": 102345, "cmsn_tax": 2551, "prft_rt": 9.42, "collected_at": "2026-06-05T21:06:49.660822+09:00"}
|
||||
{"date": "2026-06-05", "account": "ISA", "owner": "본인", "code": "0177A0", "name": "WON 두산그룹포커스", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 44, "sell_avg": 12111, "sell_amt": 532890, "pl_amt": 31220, "cmsn_tax": 70, "prft_rt": 6.22, "collected_at": "2026-06-05T21:06:49.660822+09:00"}
|
||||
{"date": "2026-06-05", "account": "ISA", "owner": "본인", "code": "233740", "name": "KODEX 코스닥150레버리지", "buy_qty": 116, "buy_avg": 12880, "buy_amt": 1494080, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 220, "prft_rt": 0.0, "collected_at": "2026-06-05T21:06:49.660822+09:00"}
|
||||
{"date": "2026-06-08", "account": "일반", "owner": "본인", "code": "000660", "name": "SK하이닉스", "buy_qty": 1, "buy_avg": 1970000, "buy_amt": 1970000, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 290, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
|
||||
{"date": "2026-06-08", "account": "일반", "owner": "본인", "code": "005930", "name": "삼성전자", "buy_qty": 1, "buy_avg": 301000, "buy_amt": 301000, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 40, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
|
||||
{"date": "2026-06-08", "account": "일반", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 12, "buy_avg": 21200, "buy_amt": 254400, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 30, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
|
||||
{"date": "2026-06-08", "account": "ISA", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 12, "buy_avg": 21100, "buy_amt": 253200, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 30, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
|
||||
{"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "000660", "name": "SK하이닉스", "buy_qty": 1, "buy_avg": 1970000, "buy_amt": 1970000, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 290, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
|
||||
{"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "005930", "name": "삼성전자", "buy_qty": 10, "buy_avg": 299750, "buy_amt": 2997500, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 440, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
|
||||
|
||||
Reference in New Issue
Block a user