auto: 일일 백업 2026-06-09 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-09 02:00:01 +09:00
parent 7f3994d98d
commit 69ef9c09e8
20 changed files with 3170 additions and 0 deletions
+3
View File
@@ -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` | 평일 0915: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` 등.
+4
View File
@@ -0,0 +1,4 @@
"""주식 자동매매 시뮬레이션(페이퍼 트레이딩) 엔진.
실주문 orders/ 와 완전 분리 — 가상 자본으로만 동작한다.
"""
+144
View File
@@ -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()
+124
View File
@@ -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']}")
+226
View File
@@ -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'),
}
+115
View File
@@ -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
+158
View File
@@ -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()
+177
View File
@@ -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
+294
View File
@@ -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','')}")
+83
View File
@@ -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
+208
View File
@@ -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,
}
+243
View File
@@ -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
+95
View File
@@ -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}")
+165
View File
@@ -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'])}")
+109
View File
@@ -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"}