<timestamp>Thursday, Aug 13, 2026, 6:19 PM (UTC+9)</timestamp>
<user_query>
오늘 틱 호가 히스토리 등등 디비에 쌓은거 표로 정리좀 얼마나 쌓였는가
중요확인 사항은 키움과 키스의 차이 정말 키움의 5배가 맞는지
</user_query>


오늘 DB에 쌓인 틱·호가 히스토리 규모를 표로 정리하고, 키움 vs 한투(KIS)가 정말 약 5배인지 스키마 확인 후 집계하겠습니다.


Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
tables = [
    'ws_ticks', 'ws_ticks_us', 'ws_orderbook', 'ws_program',
    'ls_ws_ticks', 'ls_ws_orderbook', 'minute_candles', 'ws_price_validation'
]
for t in tables:
    try:
        cols = [r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM `{t}`').fetchall()]
        print(f'=== {t} ===')
        print(', '.join(cols))
    except Exception as e:
        print(f'=== {t} === ERR: {e}')
PY

SHOW COLUMNS for tick/orderbook tables


스키마 확인됨. 오늘(20260813) 기준 source별 건수를 집계합니다.

cd /home/hoon/kis_bot && python3 - <<'PY'
import json
from collections import defaultdict
from database import TradeDB

db = TradeDB()
day = '20260813'
like = day + '%'

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

# ---- distinct sources overall sample ----
print('=== ws_ticks source values (today) ===')
rows = q(
    "SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes, MIN(tick_time) mn, MAX(tick_time) mx "
    "FROM ws_ticks WHERE tick_time LIKE %s GROUP BY source ORDER BY n DESC",
    (like,),
)
for r in rows:
    print(dict(r))

print('\n=== ws_ticks_us source (today) ===')
rows = q(
    "SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes, MIN(tick_time) mn, MAX(tick_time) mx "
    "FROM ws_ticks_us WHERE tick_time LIKE %s GROUP BY source ORDER BY n DESC",
    (like,),
)
for r in rows:
    print(dict(r))

print('\n=== ws_orderbook source (today) ===')
rows = q(
    "SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes, MIN(snap_time) mn, MAX(snap_time) mx "
    "FROM ws_orderbook WHERE snap_time LIKE %s GROUP BY source ORDER BY n DESC",
    (like,),
)
for r in rows:
    print(dict(r))

print('\n=== ws_program source (today) ===')
rows = q(
    "SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes, MIN(snap_time) mn, MAX(snap_time) mx "
    "FROM ws_program WHERE snap_time LIKE %s GROUP BY source ORDER BY n DESC",
    (like,),
)
for r in rows:
    print(dict(r))

print('\n=== ls_ws_ticks (today via chetime or ts) ===')
# check time format
sample = q("SELECT id, ts, chetime, code FROM ls_ws_ticks ORDER BY id DESC LIMIT 3")
print('sample', [dict(r) for r in sample])
rows = q(
    "SELECT COUNT(*) n, COUNT(DISTINCT code) codes FROM ls_ws_ticks WHERE chetime LIKE %s OR CAST(ts AS CHAR) LIKE %s",
    (like, like),
)
print([dict(r) for r in rows])
# try date prefixes on ts
rows = q(
    "SELECT LEFT(CAST(ts AS CHAR), 10) d, COUNT(*) n FROM ls_ws_ticks WHERE CAST(ts AS CHAR) LIKE %s GROUP BY d",
    ('2026-08-13%',),
)
print('by ts date', [dict(r) for r in rows])
rows = q(
    "SELECT COUNT(*) n, COUNT(DISTINCT code) codes FROM ls_ws_ticks WHERE chetime LIKE %s",
    (like,),
)
print('by chetime', [dict(r) for r in rows])

print('\n=== ls_ws_orderbook (today) ===')
rows = q(
    "SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes FROM ls_ws_orderbook WHERE snap_time LIKE %s GROUP BY source",
    (like,),
)
for r in rows:
    print(dict(r))

print('\n=== ws_candles source (today) ===')
try:
    cols = [r['Field'] for r in q('SHOW COLUMNS FROM ws_candles')]
    print('cols', cols)
    # find time col
    tcol = 'candle_time' if 'candle_time' in cols else ('bar_time' if 'bar_time' in cols else None)
    print('tcol', tcol)
    if tcol:
        rows = q(
            f"SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes, MIN(`{tcol}`) mn, MAX(`{tcol}`) mx "
            f"FROM ws_candles WHERE `{tcol}` LIKE %s GROUP BY source ORDER BY n DESC",
            (like,),
        )
        for r in rows:
            print(dict(r))
except Exception as e:
    print('ERR', e)

print('\n=== ws_price_validation (today) ===')
sample = q("SELECT * FROM ws_price_validation ORDER BY id DESC LIMIT 2")
print('sample', [dict(r) for r in sample])
rows = q(
    "SELECT COUNT(*) n FROM ws_price_validation WHERE CAST(ts AS CHAR) LIKE %s OR CAST(ts AS CHAR) LIKE %s",
    ('2026-08-13%', like),
)
print([dict(r) for r in rows])
PY

Aggregate today tick/orderbook by source


전체 건수만으론 KIS가 더 많습니다. 동일 종목 기준 밀도·시간대별 비율을 더 집계합니다.

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
day = '20260813'
like = day + '%'

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

# Overlap codes: kiwoom vs kis tick density
print('=== overlap code tick ratio (kiwoom/kis) ===')
rows = q(
    """
    SELECT
      k.code,
      k.n AS kis_n,
      w.n AS kiwoom_n,
      ROUND(w.n / NULLIF(k.n, 0), 3) AS ratio_kw_over_kis
    FROM (
      SELECT code, COUNT(*) n FROM ws_ticks
      WHERE tick_time LIKE %s AND source='kis' GROUP BY code
    ) k
    INNER JOIN (
      SELECT code, COUNT(*) n FROM ws_ticks
      WHERE tick_time LIKE %s AND source='kiwoom' GROUP BY code
    ) w ON k.code = w.code
    ORDER BY ratio_kw_over_kis DESC
    """,
    (like, like),
)
ratios = [float(r['ratio_kw_over_kis'] or 0) for r in rows]
print(f'overlap_codes={len(rows)}')
if ratios:
    ratios_sorted = sorted(ratios)
    import statistics as st
    def pct(p):
        i = int(round((len(ratios_sorted)-1)*p/100))
        return ratios_sorted[i]
    print({
        'median': round(st.median(ratios), 3),
        'mean': round(st.mean(ratios), 3),
        'p10': round(pct(10), 3),
        'p25': round(pct(25), 3),
        'p75': round(pct(75), 3),
        'p90': round(pct(90), 3),
        'min': round(min(ratios), 3),
        'max': round(max(ratios), 3),
        'gt_1': sum(1 for x in ratios if x > 1),
        'gt_2': sum(1 for x in ratios if x > 2),
        'gt_5': sum(1 for x in ratios if x >= 5),
        'lt_1': sum(1 for x in ratios if x < 1),
    })
    print('TOP10 kw/kis:')
    for r in rows[:10]:
        print(dict(r))
    print('BOTTOM10 kw/kis:')
    for r in rows[-10:]:
        print(dict(r))

# only-kis / only-kiwoom
only = q(
    """
    SELECT
      SUM(CASE WHEN kis_n>0 AND kiwoom_n=0 THEN 1 ELSE 0 END) only_kis,
      SUM(CASE WHEN kis_n=0 AND kiwoom_n>0 THEN 1 ELSE 0 END) only_kiwoom,
      SUM(CASE WHEN kis_n>0 AND kiwoom_n>0 THEN 1 ELSE 0 END) both,
      SUM(kis_n) sum_kis,
      SUM(kiwoom_n) sum_kiwoom
    FROM (
      SELECT
        COALESCE(k.code, w.code) code,
        COALESCE(k.n,0) kis_n,
        COALESCE(w.n,0) kiwoom_n
      FROM (
        SELECT code, COUNT(*) n FROM ws_ticks WHERE tick_time LIKE %s AND source='kis' GROUP BY code
      ) k
      LEFT JOIN (
        SELECT code, COUNT(*) n FROM ws_ticks WHERE tick_time LIKE %s AND source='kiwoom' GROUP BY code
      ) w ON k.code=w.code
      UNION
      SELECT
        COALESCE(k.code, w.code) code,
        COALESCE(k.n,0) kis_n,
        COALESCE(w.n,0) kiwoom_n
      FROM (
        SELECT code, COUNT(*) n FROM ws_ticks WHERE tick_time LIKE %s AND source='kiwoom' GROUP BY code
      ) w
      LEFT JOIN (
        SELECT code, COUNT(*) n FROM ws_ticks WHERE tick_time LIKE %s AND source='kis' GROUP BY code
      ) k ON k.code=w.code
    ) x
    """,
    (like, like, like, like),
)
print('coverage', dict(only[0]))

# hourly
print('\n=== hourly ticks ===')
rows = q(
    """
    SELECT LEFT(tick_time,10) hh, source, COUNT(*) n
    FROM ws_ticks WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
    GROUP BY hh, source ORDER BY hh, source
    """,
    (like,),
)
from collections import defaultdict
by = defaultdict(dict)
for r in rows:
    by[r['hh']][r['source']] = r['n']
for hh in sorted(by):
    k = by[hh].get('kis', 0)
    w = by[hh].get('kiwoom', 0)
    ratio = round(w/k, 3) if k else None
    print(hh, 'kis', k, 'kiwoom', w, 'kw/kis', ratio)

# orderbook hourly
print('\n=== orderbook hourly kiwoom_0d ===')
rows = q(
    """
    SELECT LEFT(snap_time,10) hh, COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_orderbook WHERE snap_time LIKE %s AND source='kiwoom_0d'
    GROUP BY hh ORDER BY hh
    """,
    (like,),
)
for r in rows:
    print(dict(r))

# candles detail
print('\n=== ws_candles timeframe x source ===')
rows = q(
    """
    SELECT timeframe, source, COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_candles WHERE candle_time LIKE %s
    GROUP BY timeframe, source ORDER BY timeframe, n DESC
    """,
    (like,),
)
for r in rows:
    print(dict(r))

# distinct codes totals
print('\n=== totals ===')
for src in ('kis', 'kiwoom'):
    r = q(
        "SELECT COUNT(*) n, COUNT(DISTINCT code) codes FROM ws_ticks WHERE tick_time LIKE %s AND source=%s",
        (like, src),
    )[0]
    print(src, dict(r))

# avg ticks per code
print('\n=== avg ticks/code overlap ===')
r = q(
    """
    SELECT
      ROUND(AVG(k.n),1) avg_kis,
      ROUND(AVG(w.n),1) avg_kiwoom,
      ROUND(SUM(w.n)/NULLIF(SUM(k.n),0),3) overall_ratio
    FROM (
      SELECT code, COUNT(*) n FROM ws_ticks WHERE tick_time LIKE %s AND source='kis' GROUP BY code
    ) k
    INNER JOIN (
      SELECT code, COUNT(*) n FROM ws_ticks WHERE tick_time LIKE %s AND source='kiwoom' GROUP BY code
    ) w ON k.code=w.code
    """,
    (like, like),
)
print(dict(r[0]))
PY

Per-code and hourly Kiwoom vs KIS ratios


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
from collections import defaultdict
import statistics as st
db = TradeDB()
day = '20260813'
like = day + '%'

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

# coverage without reserved word
print('=== coverage ===')
rows = q(
    """
    SELECT
      SUM(CASE WHEN kis_n>0 AND kiwoom_n=0 THEN 1 ELSE 0 END) AS only_kis,
      SUM(CASE WHEN kis_n=0 AND kiwoom_n>0 THEN 1 ELSE 0 END) AS only_kiwoom,
      SUM(CASE WHEN kis_n>0 AND kiwoom_n>0 THEN 1 ELSE 0 END) AS both_cnt,
      SUM(kis_n) AS sum_kis,
      SUM(kiwoom_n) AS sum_kiwoom
    FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kiwoom_n
      FROM ws_ticks
      WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
      GROUP BY code
    ) x
    """,
    (like,),
)
print(dict(rows[0]))

print('\n=== hourly ticks ===')
rows = q(
    """
    SELECT LEFT(tick_time,10) hh, source, COUNT(*) n
    FROM ws_ticks WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
    GROUP BY hh, source ORDER BY hh, source
    """,
    (like,),
)
by = defaultdict(dict)
for r in rows:
    by[r['hh']][r['source']] = r['n']
hourly = []
for hh in sorted(by):
    k = by[hh].get('kis', 0)
    w = by[hh].get('kiwoom', 0)
    ratio = round(w/k, 3) if k else None
    hourly.append({'hh': hh, 'kis': k, 'kiwoom': w, 'ratio': ratio})
    print(hh, 'kis', k, 'kiwoom', w, 'kw/kis', ratio)

print('\n=== orderbook hourly ===')
ob_h = []
rows = q(
    """
    SELECT LEFT(snap_time,10) hh, COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_orderbook WHERE snap_time LIKE %s AND source='kiwoom_0d'
    GROUP BY hh ORDER BY hh
    """,
    (like,),
)
for r in rows:
    ob_h.append(dict(r))
    print(dict(r))

print('\n=== orderbook filter_eval ===')
print(dict(q(
    "SELECT COUNT(*) n, COUNT(DISTINCT code) codes FROM ws_orderbook WHERE snap_time LIKE %s AND source='filter_eval'",
    (like,),
)[0]))

print('\n=== candles ===')
candles = []
rows = q(
    """
    SELECT timeframe, source, COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_candles WHERE candle_time LIKE %s
    GROUP BY timeframe, source ORDER BY timeframe, n DESC
    """,
    (like,),
)
for r in rows:
    candles.append(dict(r))
    print(dict(r))

print('\n=== avg overlap ===')
print(dict(q(
    """
    SELECT
      ROUND(AVG(kis_n),1) avg_kis,
      ROUND(AVG(kiwoom_n),1) avg_kiwoom,
      ROUND(SUM(kiwoom_n)/NULLIF(SUM(kis_n),0),3) overall_ratio,
      ROUND(AVG(kiwoom_n/NULLIF(kis_n,0)),3) avg_of_ratios
    FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kiwoom_n
      FROM ws_ticks
      WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
      GROUP BY code
      HAVING kis_n>0 AND kiwoom_n>0
    ) x
    """,
    (like,),
)[0]))

# ratio buckets
print('\n=== ratio buckets (overlap) ===')
rows = q(
    """
    SELECT
      CASE
        WHEN r < 0.5 THEN 'a_<0.5'
        WHEN r < 1.0 THEN 'b_0.5-1'
        WHEN r < 1.5 THEN 'c_1-1.5'
        WHEN r < 2.0 THEN 'd_1.5-2'
        WHEN r < 3.0 THEN 'e_2-3'
        WHEN r < 5.0 THEN 'f_3-5'
        ELSE 'g_>=5'
      END bucket,
      COUNT(*) cnt
    FROM (
      SELECT kiwoom_n / NULLIF(kis_n,0) r
      FROM (
        SELECT code,
          SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
          SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kiwoom_n
        FROM ws_ticks
        WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
        GROUP BY code
        HAVING kis_n>0 AND kiwoom_n>0
      ) x
    ) y
    GROUP BY bucket ORDER BY bucket
    """,
    (like,),
)
for r in rows:
    print(dict(r))

# table sizes / earliest recv
print('\n=== recv_ts span ===')
for src in ('kis','kiwoom'):
    r = q(
        "SELECT MIN(recv_ts) mn, MAX(recv_ts) mx FROM ws_ticks WHERE tick_time LIKE %s AND source=%s",
        (like, src),
    )[0]
    print(src, dict(r))

# US ticks
print('\n=== US ===')
print(dict(q(
    "SELECT COUNT(*) n, COUNT(DISTINCT code) codes, MIN(tick_time) mn, MAX(tick_time) mx FROM ws_ticks_us WHERE tick_time LIKE %s",
    (like,),
)[0]))

# program
print('\n=== program ===')
print(dict(q(
    "SELECT source, COUNT(*) n FROM ws_program WHERE snap_time LIKE %s GROUP BY source",
    (like,),
)[0]) if q("SELECT source, COUNT(*) n FROM ws_program WHERE snap_time LIKE %s GROUP BY source", (like,)) else 'empty')

# top volume codes both
print('\n=== top20 by kis ticks (with kiwoom) ===')
rows = q(
    """
    SELECT code, kis_n, kiwoom_n, ROUND(kiwoom_n/NULLIF(kis_n,0),2) r
    FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kiwoom_n
      FROM ws_ticks WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
      GROUP BY code
    ) x
    ORDER BY kis_n DESC LIMIT 20
    """,
    (like,),
)
for r in rows:
    print(dict(r))
PY

Finish hourly coverage candles and buckets


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
like = '20260813%'

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

# fair compare: overlap only totals already have overall 1.258
# exclude ultra-liquid kis-only from narrative
print('kis-only top codes:')
rows = q(
    """
    SELECT code, kis_n FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kiwoom_n
      FROM ws_ticks WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
      GROUP BY code
      HAVING kiwoom_n=0 AND kis_n>0
    ) x ORDER BY kis_n DESC
    """,
    (like,),
)
for r in rows:
    print(dict(r))
print('kis_only_codes', len(rows), 'sum', sum(int(r['kis_n']) for r in rows))

# weighted median / high-volume overlap (kis_n>=1000)
print('\nhigh volume overlap kis_n>=1000:')
rows = q(
    """
    SELECT
      COUNT(*) cnt,
      ROUND(SUM(kiwoom_n)/NULLIF(SUM(kis_n),0),3) overall,
      ROUND(AVG(kiwoom_n/NULLIF(kis_n,0)),3) avg_ratio,
      ROUND(AVG(kis_n),1) avg_kis,
      ROUND(AVG(kiwoom_n),1) avg_kw
    FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kiwoom_n
      FROM ws_ticks WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
      GROUP BY code
      HAVING kis_n>=1000 AND kiwoom_n>0
    ) x
    """,
    (like,),
)
print(dict(rows[0]))

# median for high volume
rows = q(
    """
    SELECT kiwoom_n/NULLIF(kis_n,0) r FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kiwoom_n
      FROM ws_ticks WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
      GROUP BY code
      HAVING kis_n>=1000 AND kiwoom_n>0
    ) x ORDER BY r
    """,
    (like,),
)
rs = [float(r['r']) for r in rows]
import statistics as st
print('n', len(rs), 'median', round(st.median(rs),3) if rs else None, 'p90', round(sorted(rs)[int(0.9*(len(rs)-1))],3) if rs else None)

# orderbook vs kiwoom ticks correlation
print('\norderbook vs kiwoom ticks:')
print(dict(q(
    """
    SELECT
      (SELECT COUNT(*) FROM ws_orderbook WHERE snap_time LIKE %s AND source='kiwoom_0d') ob,
      (SELECT COUNT(*) FROM ws_ticks WHERE tick_time LIKE %s AND source='kiwoom') tk,
      ROUND(
        (SELECT COUNT(*) FROM ws_orderbook WHERE snap_time LIKE %s AND source='kiwoom_0d') /
        NULLIF((SELECT COUNT(*) FROM ws_ticks WHERE tick_time LIKE %s AND source='kiwoom'),0)
      , 3) ob_per_tick
    """,
    (like, like, like, like),
)[0]))

# table totals all day summary for canvas
print('\nDONE')
PY

KIS-only skew and high-volume ratios


Write

import {
  BarChart,
  Callout,
  Card,
  CardBody,
  CardHeader,
  Grid,
  H1,
  H2,
  Stack,
  Stat,
  Table,
  Text,
} from "cursor/canvas";

/**
 * 2026-08-13 DB 적재량: 틱/호가/봉 · 키움 vs KIS 배수 검증
 * Source: MariaDB kis_quant_db · tick_time/snap_time LIKE 20260813%
 */
export default function TodayTickObDbInventory() {
  return (
    <Stack gap={20}>
      <Stack gap={6}>

오늘 DB 적재량 — 틱·호가·봉

        <Text tone="secondary">
          2026-08-13 · kis_quant_db · ws_ticks / ws_orderbook / ws_candles /
          ws_ticks_us
        </Text>
      </Stack>

      <Callout tone="danger">
        결론: DB에 쌓인 국장 틱 기준으로 「키움 = KIS의 5배」는 성립하지 않음.
        동일 종목(319개) 합계 배수 ≈ 1.26×, 종목별 중앙값 ≈ 1.08×. ≥5×는
        15종목(4.7%)뿐.
      </Callout>

      <Grid columns={4} gap={12}>
        <Stat
          value="513,093"
          label="KIS 국장 틱"
          tone="info"
        />
        <Stat
          value="284,879"
          label="키움 국장 틱"
          tone="neutral"
        />
        <Stat
          value="0.55×"
          label="키움/KIS 전체 합계"
          tone="warning"
        />
        <Stat
          value="1.26×"
          label="동일종목 합계 배수"
          tone="success"
        />
      </Grid>

1) 테이블별 오늘 적재

      <Table
        headers={[
          "테이블",
          "source",
          "건수",
          "종목수",
          "시간 범위",
          "비고",
        ]}
        columnAlign={["left", "left", "right", "right", "left", "left"]}
        rows={[
          [
            "ws_ticks",
            "kis",
            "513,093",
            "331",
            "08:30~15:59",
            "국장 체결",
          ],
          [
            "ws_ticks",
            "kiwoom",
            "284,879",
            "321",
            "09:00~15:59",
            "국장 체결",
          ],
          [
            "ws_ticks_us",
            "kis_us",
            "136,639",
            "4",
            "00:00~23:59",
            "해외 틱",
          ],
          [
            "ws_orderbook",
            "kiwoom_0d",
            "282,119",
            "314",
            "09:00~15:32",
            "연속 호가(~3초)",
          ],
          [
            "ws_orderbook",
            "filter_eval",
            "1,413",
            "42",
            "09:01~15:14",
            "매수판정 스냅",
          ],
          [
            "ws_program",
            "filter_eval",
            "11",
            "3",
            "09:39~14:54",
            "판정 시만",
          ],
          [
            "ws_candles 1m",
            "rest",
            "88,298",
            "334",
            "09:00~15:19",
            "REST 웜업/보정",
          ],
          [
            "ws_candles 1m",
            "kis",
            "10,775",
            "306",
            "장중",
            "WS 롤업/직적",
          ],
          [
            "ws_candles 1m",
            "kiwoom",
            "2,849",
            "295",
            "09:03~",
            "키움 WS",
          ],
          [
            "ws_candles 3m",
            "rollup_1m",
            "26,324",
            "333",
            "09:00~15:15",
            "1분→3분",
          ],
          [
            "ls_ws_ticks / ls_ws_orderbook",
            "—",
            "0",
            "0",
            "—",
            "오늘 0건(최근 샘플 8/7)",
          ],
        ]}
      />

2) 키움 vs KIS 틱 — 왜 전체는 키움이 더 적어 보이나

      <Callout tone="warning">
        KIS만 있는 대형주·ETF 12종목이 틱 286,608건을 차지(삼성전자 82,857 /
        SK하이닉스 69,230 / KODEX200 등). 키움 유니버스에 안 올라가 전체 합계가
        왜곡됨. 공정 비교는 양쪽 모두 있는 종목만.
      </Callout>
      <Grid columns={3} gap={12}>
        <Card>
          <CardHeader>커버리지</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>양측 동시: 319종목</Text>
              <Text>KIS만: 12종목</Text>
              <Text>키움만: 2종목</Text>
            </Stack>
          </CardBody>
        </Card>
        <Card>
          <CardHeader>동일종목 합계</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>종목당 평균 KIS 710틱</Text>
              <Text>종목당 평균 키움 893틱</Text>
              <Text>합계 배수 키움/KIS = 1.258×</Text>
            </Stack>
          </CardBody>
        </Card>
        <Card>
          <CardHeader>종목별 배수 분포</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>중앙값 1.083×</Text>
              <Text>평균(산술) 1.565×</Text>
              <Text>≥5× : 15종목 (4.7%)</Text>
            </Stack>
          </CardBody>
        </Card>
      </Grid>

      <Table
        headers={["키움/KIS 배수 구간", "종목 수", "해석"]}
        columnAlign={["left", "right", "left"]}
        rowTone={[
          "neutral",
          "neutral",
          "success",
          "info",
          "info",
          "warning",
          "danger",
        ]}
        rows={[
          ["< 0.5×", "15", "키움이 훨씬 적음"],
          ["0.5 ~ 1×", "92", "KIS가 더 많음"],
          ["1 ~ 1.5×", "137", "거의 비슷·소폭 키움 우세 (최빈)"],
          ["1.5 ~ 2×", "30", "키움 다소 밀집"],
          ["2 ~ 3×", "17", ""],
          ["3 ~ 5×", "13", ""],
          ["≥ 5×", "15", "5배 주장에 해당하는 소수"],
        ]}
      />

3) 시간대별 틱 (키움/KIS)

      <BarChart
        title="시간대별 국장 틱 건수"
        height={240}
        categories={[
          "09시",
          "10시",
          "11시",
          "12시",
          "13시",
          "14시",
          "15시",
        ]}
        series={[
          {
            name: "KIS",
            data: [117701, 95852, 57277, 56426, 71031, 86394, 28400],
          },
          {
            name: "키움",
            data: [82459, 62586, 25125, 26390, 29820, 45260, 13239],
          },
        ]}
      />
      <Text tone="secondary" size="small">
        Source: ws_ticks · 20260813 · 시간대별 키움/KIS 비율은 0.42~0.70 (항상
        1 미만). 08시는 KIS 프리마켓 12건만.
      </Text>

4) 호가 시계열 (키움 전용)

      <Grid columns={3} gap={12}>
        <Stat value="282,119" label="kiwoom_0d 호가" />
        <Stat value="314" label="호가 종목수" />
        <Stat value="0.99" label="호가건수/키움틱" />
      </Grid>
      <Text tone="secondary">
        호가는 KIS source 없음(오늘). 키움 0D 연속저장 ≈ 틱 건수와 거의 1:1
        (구독·덤프 간격 영향). filter_eval은 매수 판정 순간 스냅 1,413건.
      </Text>

5) 활발 종목(KIS≥1000) 공정 비교

      <Callout tone="info">
        유동성 큰 겹침 종목에서도 중앙값이 약 1.08×. 「항상 5배」가 아니라
        「대체로 비슷~소폭 키움 우세」가 오늘 DB 진실에 가깝다.
      </Callout>
    </Stack>
  );
}


**결론: DB 기준 「키움 = KIS 5배」는 오늘 성립하지 않습니다.** 동일 종목 합계는 약 **1.26×**, 종목별 중앙값은 **1.08×**입니다. (≥5×는 319종목 중 15개, 4.7%)

상세 표·차트는 캔버스 `today-tick-ob-db-inventory`에 정리해 두었습니다.

### 오늘(2026-08-13) 적재 요약

| 테이블 | source | 건수 | 종목 |
|--------|--------|------|------|
| `ws_ticks` | kis | **513,093** | 331 |
| `ws_ticks` | kiwoom | **284,879** | 321 |
| `ws_ticks_us` | kis_us | 136,639 | 4 |
| `ws_orderbook` | kiwoom_0d | **282,119** | 314 |
| `ws_orderbook` | filter_eval | 1,413 | 42 |
| `ws_program` | filter_eval | 11 | 3 |
| `ws_candles` 1m | rest / kis / kiwoom | 88,298 / 10,775 / 2,849 | — |
| `ws_candles` 3m | rollup_1m | 26,324 | 333 |
| LS 틱·호가 | — | **0** | (최근 샘플 8/7) |

### 키움 vs KIS — 왜 전체가 5배가 아닌가

1. **전체 합계**: 키움/KIS = **0.55×** (키움이 더 적음)
   → 삼성전자·하이닉스·KODEX 등 **KIS만 있는 12종목**이 틱 **286,608**건을 차지해서 합계가 왜곡됨.
2. **공정 비교(양쪽 다 있는 319종목)**: 합계 **1.26×**, 중앙값 **1.08×**, 활발 종목(KIS≥1000)도 중앙값 **1.08×**.
3. **시간대별**도 키움/KIS가 **0.42~0.70**으로 항상 1 미만.
4. **호가**는 오늘 **키움(`kiwoom_0d`)만** 쌓임(약 28만). 키움 틱과 거의 1:1.

정리하면, “키움이 5배”는 **일부 종목 예외**일 뿐이고, 오늘 DB에 쌓인 틱으로는 **대체로 비슷~소폭 키움 우세(≈1.1×)**가 맞습니다.


<timestamp>Thursday, Aug 13, 2026, 6:29 PM (UTC+9)</timestamp>
<user_query>
분명 5배는 더 온다고해서 키스로 바꿀까하고 있는데 별 차이가 없네 근데 1분 캔들은 엄청 차이나네 공정하게 영구구독은 빼고 계산해야지
</user_query>


공정 비교를 위해 영구구독 종목을 제외하고, 1분봉 차이가 어디서 나는지부터 코드·DB에서 확인하겠습니다.


Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
like = '20260813%'

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

cols = [r['Field'] for r in q('SHOW COLUMNS FROM permanent_subscriptions')]
print('perm cols', cols)
rows = q('SELECT * FROM permanent_subscriptions ORDER BY market_type, code')
print('perm n', len(rows))
for r in rows:
    print(dict(r))
PY

List permanent_subscriptions rows after schema


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
import statistics as st
db = TradeDB()
like = '20260813%'
perm = ['000660','005380','005930','069500','229200','379810','466930']
# placeholders
ph = ','.join(['%s']*len(perm))

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

print('=== TICKS excluding permanent KR ===')
rows = q(
    f"""
    SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_ticks
    WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
      AND code NOT IN ({ph})
    GROUP BY source
    """,
    (like, *perm),
)
for r in rows:
    print(dict(r))

print('\n=== TICKS permanent only ===')
rows = q(
    f"""
    SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_ticks
    WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
      AND code IN ({ph})
    GROUP BY source
    """,
    (like, *perm),
)
for r in rows:
    print(dict(r))

print('\n=== overlap NON-perm tick ratio ===')
row = q(
    f"""
    SELECT
      COUNT(*) overlap,
      SUM(kis_n) sum_kis,
      SUM(kw_n) sum_kw,
      ROUND(SUM(kw_n)/NULLIF(SUM(kis_n),0),3) overall,
      ROUND(AVG(kis_n),1) avg_kis,
      ROUND(AVG(kw_n),1) avg_kw,
      ROUND(AVG(kw_n/NULLIF(kis_n,0)),3) avg_ratio
    FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kw_n
      FROM ws_ticks
      WHERE tick_time LIKE %s AND source IN ('kis','kiwoom')
        AND code NOT IN ({ph})
      GROUP BY code
      HAVING kis_n>0 AND kw_n>0
    ) x
    """,
    (like, *perm),
)[0]
print(dict(row))

# median / buckets
rs = [float(r['r']) for r in q(
    f"""
    SELECT kw_n/NULLIF(kis_n,0) r FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kw_n
      FROM ws_ticks
      WHERE tick_time LIKE %s AND source IN ('kis','kiwoom') AND code NOT IN ({ph})
      GROUP BY code HAVING kis_n>0 AND kw_n>0
    ) x
    """,
    (like, *perm),
)]
rs.sort()
def pct(p):
    i = int(round((len(rs)-1)*p/100))
    return rs[i]
print({
    'n': len(rs),
    'median': round(st.median(rs),3),
    'p10': round(pct(10),3),
    'p90': round(pct(90),3),
    'min': round(min(rs),3),
    'max': round(max(rs),3),
    'gt5': sum(1 for x in rs if x>=5),
    'gt2': sum(1 for x in rs if x>2),
    'lt1': sum(1 for x in rs if x<1),
})

print('\n=== 1m CANDLES by source (all / excl perm / perm only) ===')
for label, extra, extra_params in [
    ('ALL', '', ()),
    ('EXCL_PERM', f'AND code NOT IN ({ph})', tuple(perm)),
    ('PERM_ONLY', f'AND code IN ({ph})', tuple(perm)),
]:
    rows = q(
        f"""
        SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes
        FROM ws_candles
        WHERE candle_time LIKE %s AND timeframe=1
        {extra}
        GROUP BY source ORDER BY n DESC
        """,
        (like, *extra_params),
    )
    print(label)
    for r in rows:
        print(' ', dict(r))

print('\n=== 1m overlap NON-perm kis vs kiwoom bars ===')
row = q(
    f"""
    SELECT
      COUNT(*) overlap,
      SUM(kis_n) sum_kis,
      SUM(kw_n) sum_kw,
      ROUND(SUM(kw_n)/NULLIF(SUM(kis_n),0),3) overall,
      ROUND(AVG(kis_n),1) avg_kis,
      ROUND(AVG(kw_n),1) avg_kw,
      ROUND(AVG(kw_n/NULLIF(kis_n,0)),3) avg_ratio
    FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kw_n
      FROM ws_candles
      WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kis','kiwoom')
        AND code NOT IN ({ph})
      GROUP BY code
      HAVING kis_n>0 AND kw_n>0
    ) x
    """,
    (like, *perm),
)[0]
print(dict(row))

rs = [float(r['r']) for r in q(
    f"""
    SELECT kw_n/NULLIF(kis_n,0) r, kis_n, kw_n, code FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kw_n
      FROM ws_candles
      WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kis','kiwoom')
        AND code NOT IN ({ph})
      GROUP BY code HAVING kis_n>0 AND kw_n>0
    ) x
    """,
    (like, *perm),
)]
# wait I didn't select r properly in first - let me do dedicated
rows = q(
    f"""
    SELECT code, kis_n, kw_n, ROUND(kw_n/NULLIF(kis_n,0),3) r FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kw_n
      FROM ws_candles
      WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kis','kiwoom')
        AND code NOT IN ({ph})
      GROUP BY code HAVING kis_n>0 AND kw_n>0
    ) x ORDER BY r DESC
    """,
    (like, *perm),
)
rs = [float(r['r']) for r in rows]
print('candle overlap n', len(rows), 'median', round(st.median(rs),3) if rs else None,
      'mean', round(st.mean(rs),3) if rs else None,
      'min', min(rs) if rs else None, 'max', max(rs) if rs else None)
print('TOP10 kw/kis 1m bars')
for r in rows[:10]:
    print(dict(r))
print('BOTTOM10')
for r in rows[-10:]:
    print(dict(r))

# codes with only one source (non-perm 1m)
print('\n=== 1m source coverage NON-perm ===')
print(dict(q(
    f"""
    SELECT
      SUM(CASE WHEN kis_n>0 AND kw_n=0 THEN 1 ELSE 0 END) only_kis,
      SUM(CASE WHEN kis_n=0 AND kw_n>0 THEN 1 ELSE 0 END) only_kw,
      SUM(CASE WHEN kis_n>0 AND kw_n>0 THEN 1 ELSE 0 END) both_cnt,
      SUM(kis_n) sum_kis,
      SUM(kw_n) sum_kw
    FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kw_n
      FROM ws_candles
      WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kis','kiwoom')
        AND code NOT IN ({ph})
      GROUP BY code
    ) x
    """,
    (like, *perm),
)[0]))

# unique minute coverage: distinct candle_time per code
print('\n=== unique minutes per code (overlap, excl perm) ===')
print(dict(q(
    f"""
    SELECT
      ROUND(AVG(kis_m),1) avg_kis_min,
      ROUND(AVG(kw_m),1) avg_kw_min,
      ROUND(SUM(kw_m)/NULLIF(SUM(kis_m),0),3) overall
    FROM (
      SELECT code,
        COUNT(DISTINCT CASE WHEN source='kis' THEN candle_time END) kis_m,
        COUNT(DISTINCT CASE WHEN source='kiwoom' THEN candle_time END) kw_m
      FROM ws_candles
      WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kis','kiwoom')
        AND code NOT IN ({ph})
      GROUP BY code
      HAVING kis_m>0 AND kw_m>0
    ) x
    """,
    (like, *perm),
)[0]))

# rest vs ws for same non-perm codes
print('\n=== rest 1m excl perm ===')
print(dict(q(
    f"""
    SELECT COUNT(*) n, COUNT(DISTINCT code) codes,
      ROUND(COUNT(*)/COUNT(DISTINCT code),1) avg_bars
    FROM ws_candles WHERE candle_time LIKE %s AND timeframe=1 AND source='rest'
      AND code NOT IN ({ph})
    """,
    (like, *perm),
)[0]))
print('rest perm only', dict(q(
    f"""
    SELECT COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_candles WHERE candle_time LIKE %s AND timeframe=1 AND source='rest'
      AND code IN ({ph})
    """,
    (like, *perm),
)[0]))
PY

Fair tick/candle stats excluding permanents


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
import statistics as st
db = TradeDB()
like = '20260813%'
perm = ['000660','005380','005930','069500','229200','379810','466930']
ph = ','.join(['%s']*len(perm))

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

# tick-minutes vs candle-minutes for overlap non-perm
print('=== tick-minutes vs 1m candle-minutes (overlap non-perm) ===')
print(dict(q(
    f"""
    SELECT
      COUNT(*) n,
      ROUND(AVG(kis_tm),1) avg_kis_tickmin,
      ROUND(AVG(kw_tm),1) avg_kw_tickmin,
      ROUND(AVG(kis_cm),1) avg_kis_cmin,
      ROUND(AVG(kw_cm),1) avg_kw_cmin,
      ROUND(SUM(kw_tm)/NULLIF(SUM(kis_tm),0),3) tickmin_ratio,
      ROUND(SUM(kw_cm)/NULLIF(SUM(kis_cm),0),3) cmin_ratio,
      ROUND(SUM(kis_ticks)/NULLIF(SUM(kis_tm),0),1) kis_ticks_per_min,
      ROUND(SUM(kw_ticks)/NULLIF(SUM(kw_tm),0),1) kw_ticks_per_min,
      ROUND(AVG(kw_ticks/NULLIF(kis_ticks,0)),3) avg_tick_ratio,
      ROUND(SUM(kw_ticks)/NULLIF(SUM(kis_ticks),0),3) overall_tick_ratio
    FROM (
      SELECT t.code,
        t.kis_ticks, t.kw_ticks, t.kis_tm, t.kw_tm,
        COALESCE(c.kis_cm,0) kis_cm, COALESCE(c.kw_cm,0) kw_cm
      FROM (
        SELECT code,
          SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_ticks,
          SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kw_ticks,
          COUNT(DISTINCT CASE WHEN source='kis' THEN LEFT(tick_time,12) END) kis_tm,
          COUNT(DISTINCT CASE WHEN source='kiwoom' THEN LEFT(tick_time,12) END) kw_tm
        FROM ws_ticks
        WHERE tick_time LIKE %s AND source IN ('kis','kiwoom') AND code NOT IN ({ph})
        GROUP BY code
        HAVING kis_ticks>0 AND kw_ticks>0
      ) t
      LEFT JOIN (
        SELECT code,
          COUNT(DISTINCT CASE WHEN source='kis' THEN candle_time END) kis_cm,
          COUNT(DISTINCT CASE WHEN source='kiwoom' THEN candle_time END) kw_cm
        FROM ws_candles
        WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kis','kiwoom')
          AND code NOT IN ({ph})
        GROUP BY code
      ) c ON t.code=c.code
    ) x
    """,
    (like, *perm, like, *perm),
)[0]))

# candle persist rate: candles / tick-minutes
print('\n=== persist rate candles/tickmin ===')
print(dict(q(
    f"""
    SELECT
      ROUND(SUM(kis_cm)/NULLIF(SUM(kis_tm),0),3) kis_persist,
      ROUND(SUM(kw_cm)/NULLIF(SUM(kw_tm),0),3) kw_persist
    FROM (
      SELECT t.code, t.kis_tm, t.kw_tm,
        COALESCE(c.kis_cm,0) kis_cm, COALESCE(c.kw_cm,0) kw_cm
      FROM (
        SELECT code,
          COUNT(DISTINCT CASE WHEN source='kis' THEN LEFT(tick_time,12) END) kis_tm,
          COUNT(DISTINCT CASE WHEN source='kiwoom' THEN LEFT(tick_time,12) END) kw_tm
        FROM ws_ticks
        WHERE tick_time LIKE %s AND source IN ('kis','kiwoom') AND code NOT IN ({ph})
        GROUP BY code
        HAVING SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END)>0
           AND SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END)>0
      ) t
      LEFT JOIN (
        SELECT code,
          COUNT(DISTINCT CASE WHEN source='kis' THEN candle_time END) kis_cm,
          COUNT(DISTINCT CASE WHEN source='kiwoom' THEN candle_time END) kw_cm
        FROM ws_candles
        WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kis','kiwoom')
          AND code NOT IN ({ph})
        GROUP BY code
      ) c ON t.code=c.code
    ) x
    """,
    (like, *perm, like, *perm),
)[0]))

# hourly 1m candles excl perm
print('\n=== hourly 1m candles excl perm ===')
rows = q(
    f"""
    SELECT LEFT(candle_time,10) hh, source, COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_candles
    WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kis','kiwoom')
      AND code NOT IN ({ph})
    GROUP BY hh, source ORDER BY hh, source
    """,
    (like, *perm),
)
from collections import defaultdict
by=defaultdict(dict)
for r in rows:
    by[r['hh']][r['source']] = (r['n'], r['codes'])
for hh in sorted(by):
    k=by[hh].get('kis',(0,0))
    w=by[hh].get('kiwoom',(0,0))
    print(hh, 'kis', k, 'kw', w, 'kw/kis', round(w[0]/k[0],3) if k[0] else None)

# is_confirmed split
print('\n=== is_confirmed 1m excl perm ===')
rows = q(
    f"""
    SELECT source, is_confirmed, COUNT(*) n
    FROM ws_candles
    WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kis','kiwoom')
      AND code NOT IN ({ph})
    GROUP BY source, is_confirmed
    """,
    (like, *perm),
)
for r in rows:
    print(dict(r))

# ticks per tick-minute distribution
print('\n=== ticks per tick-minute (density) overlap non-perm ===')
# too heavy to per-minute all? aggregate:
print(dict(q(
    f"""
    SELECT
      ROUND(SUM(n)/COUNT(*),2) avg_ticks_per_code_min,
      source
    FROM (
      SELECT source, code, LEFT(tick_time,12) m, COUNT(*) n
      FROM ws_ticks
      WHERE tick_time LIKE %s AND source IN ('kis','kiwoom') AND code NOT IN ({ph})
      GROUP BY source, code, m
    ) x
    GROUP BY source
    """,
    (like, *perm),
)))

rows = q(
    f"""
    SELECT source, COUNT(*) minutes, SUM(n) ticks, ROUND(AVG(n),2) avg_n, ROUND(MAX(n),0) max_n
    FROM (
      SELECT source, code, LEFT(tick_time,12) m, COUNT(*) n
      FROM ws_ticks
      WHERE tick_time LIKE %s AND source IN ('kis','kiwoom') AND code NOT IN ({ph})
      GROUP BY source, code, LEFT(tick_time,12)
    ) x GROUP BY source
    """,
    (like, *perm),
)
for r in rows:
    print(dict(r))

# SAME minutes both sources: density on intersection minutes
print('\n=== same code+minute both sources tick density ===')
print(dict(q(
    f"""
    SELECT
      COUNT(*) shared_mins,
      SUM(kis_n) kis_ticks,
      SUM(kw_n) kw_ticks,
      ROUND(SUM(kw_n)/NULLIF(SUM(kis_n),0),3) ratio,
      ROUND(AVG(kis_n),2) avg_kis,
      ROUND(AVG(kw_n),2) avg_kw
    FROM (
      SELECT code, m,
        SUM(CASE WHEN source='kis' THEN n ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN n ELSE 0 END) kw_n
      FROM (
        SELECT source, code, LEFT(tick_time,12) m, COUNT(*) n
        FROM ws_ticks
        WHERE tick_time LIKE %s AND source IN ('kis','kiwoom') AND code NOT IN ({ph})
        GROUP BY source, code, LEFT(tick_time,12)
      ) z
      GROUP BY code, m
      HAVING kis_n>0 AND kw_n>0
    ) x
    """,
    (like, *perm),
)[0]))
PY

Tick-minutes vs candle persist and density


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
like = '20260813%'
perm = ['000660','005380','005930','069500','229200','379810','466930']
ph = ','.join(['%s']*len(perm))

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

print('=== 1m by market excl KR perm ===')
rows = q(
    f"""
    SELECT market, source, COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_candles
    WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kis','kiwoom')
      AND code NOT IN ({ph})
    GROUP BY market, source
    """,
    (like, *perm),
)
for r in rows:
    print(dict(r))

print('\n=== overnight 00-08 / 16-23 codes ===')
rows = q(
    f"""
    SELECT source, code, COUNT(*) n, MIN(candle_time) mn, MAX(candle_time) mx
    FROM ws_candles
    WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kis','kiwoom')
      AND (LEFT(candle_time,10) < '2026081309' OR LEFT(candle_time,10) >= '2026081316')
    GROUP BY source, code ORDER BY n DESC
    """,
    (like,),
)
for r in rows[:20]:
    print(dict(r))
print('n groups', len(rows))

# FAIR: KR market, regular hours 09-15, excl perm
print('\n=== FAIR 1m KR 09-15 excl perm ===')
print(dict(q(
    f"""
    SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_candles
    WHERE candle_time LIKE %s AND timeframe=1
      AND market='KR' AND source IN ('kis','kiwoom')
      AND candle_time >= '202608130900' AND candle_time <= '202608131530'
      AND code NOT IN ({ph})
    GROUP BY source
    """,
    (like, *perm),
)[0] if False else 'split'))
rows = q(
    f"""
    SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_candles
    WHERE timeframe=1 AND market='KR' AND source IN ('kis','kiwoom')
      AND candle_time >= '202608130900' AND candle_time <= '202608131530'
      AND code NOT IN ({ph})
    GROUP BY source
    """,
    tuple(perm),
)
for r in rows:
    print(dict(r))

print('\noverlap fair 1m')
print(dict(q(
    f"""
    SELECT COUNT(*) overlap,
      SUM(kis_n) sum_kis, SUM(kw_n) sum_kw,
      ROUND(SUM(kw_n)/NULLIF(SUM(kis_n),0),3) overall,
      ROUND(AVG(kis_n),1) avg_kis, ROUND(AVG(kw_n),1) avg_kw
    FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kw_n
      FROM ws_candles
      WHERE timeframe=1 AND market='KR' AND source IN ('kis','kiwoom')
        AND candle_time >= '202608130900' AND candle_time <= '202608131530'
        AND code NOT IN ({ph})
      GROUP BY code HAVING kis_n>0 AND kw_n>0
    ) x
    """,
    tuple(perm),
)[0]))

print('\npersist KR 09-15 overlap')
print(dict(q(
    f"""
    SELECT
      ROUND(SUM(kis_cm)/NULLIF(SUM(kis_tm),0),3) kis_persist,
      ROUND(SUM(kw_cm)/NULLIF(SUM(kw_tm),0),3) kw_persist,
      ROUND(AVG(kis_tm),1) avg_kis_tm,
      ROUND(AVG(kw_tm),1) avg_kw_tm,
      ROUND(AVG(kis_cm),1) avg_kis_cm,
      ROUND(AVG(kw_cm),1) avg_kw_cm,
      ROUND(SUM(kw_ticks)/NULLIF(SUM(kis_ticks),0),3) tick_ratio,
      ROUND(SUM(kw_ticks)/NULLIF(SUM(kw_tm),0),1) kw_tpm,
      ROUND(SUM(kis_ticks)/NULLIF(SUM(kis_tm),0),1) kis_tpm
    FROM (
      SELECT t.code, t.kis_ticks, t.kw_ticks, t.kis_tm, t.kw_tm,
        COALESCE(c.kis_cm,0) kis_cm, COALESCE(c.kw_cm,0) kw_cm
      FROM (
        SELECT code,
          SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_ticks,
          SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kw_ticks,
          COUNT(DISTINCT CASE WHEN source='kis' THEN LEFT(tick_time,12) END) kis_tm,
          COUNT(DISTINCT CASE WHEN source='kiwoom' THEN LEFT(tick_time,12) END) kw_tm
        FROM ws_ticks
        WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
          AND source IN ('kis','kiwoom') AND market='KR' AND code NOT IN ({ph})
        GROUP BY code HAVING kis_ticks>0 AND kw_ticks>0
      ) t
      LEFT JOIN (
        SELECT code,
          COUNT(DISTINCT CASE WHEN source='kis' THEN candle_time END) kis_cm,
          COUNT(DISTINCT CASE WHEN source='kiwoom' THEN candle_time END) kw_cm
        FROM ws_candles
        WHERE timeframe=1 AND market='KR' AND source IN ('kis','kiwoom')
          AND candle_time >= '202608130900' AND candle_time <= '202608131530'
          AND code NOT IN ({ph})
        GROUP BY code
      ) c ON t.code=c.code
    ) x
    """,
    (*perm, *perm),
)[0]))

# shared minute density KR hours excl perm
print('\nshared minute density KR hours')
print(dict(q(
    f"""
    SELECT COUNT(*) shared_mins,
      SUM(kis_n) kis_ticks, SUM(kw_n) kw_ticks,
      ROUND(SUM(kw_n)/NULLIF(SUM(kis_n),0),3) ratio,
      ROUND(AVG(kis_n),2) avg_kis, ROUND(AVG(kw_n),2) avg_kw
    FROM (
      SELECT code, m,
        SUM(CASE WHEN source='kis' THEN n ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN n ELSE 0 END) kw_n
      FROM (
        SELECT source, code, LEFT(tick_time,12) m, COUNT(*) n
        FROM ws_ticks
        WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
          AND source IN ('kis','kiwoom') AND market='KR' AND code NOT IN ({ph})
        GROUP BY source, code, LEFT(tick_time,12)
      ) z GROUP BY code, m HAVING kis_n>0 AND kw_n>0
    ) x
    """,
    tuple(perm),
)[0]))
PY

Fair KR regular-hours candle vs tick density


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
perm = ['000660','005380','005930','069500','229200','379810','466930']
ph = ','.join(['%s']*len(perm))

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

print('=== FAIR 1m KR 09:00-15:30 excl perm ===')
rows = q(
    f"""
    SELECT source, COUNT(*) n, COUNT(DISTINCT code) codes
    FROM ws_candles
    WHERE timeframe=1 AND market='KR' AND source IN ('kis','kiwoom')
      AND candle_time >= '202608130900' AND candle_time <= '202608131530'
      AND code NOT IN ({ph})
    GROUP BY source
    """,
    tuple(perm),
)
for r in rows:
    print(dict(r))

print('\noverlap fair 1m')
print(dict(q(
    f"""
    SELECT COUNT(*) overlap,
      SUM(kis_n) sum_kis, SUM(kw_n) sum_kw,
      ROUND(SUM(kw_n)/NULLIF(SUM(kis_n),0),3) overall,
      ROUND(AVG(kis_n),1) avg_kis, ROUND(AVG(kw_n),1) avg_kw
    FROM (
      SELECT code,
        SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kw_n
      FROM ws_candles
      WHERE timeframe=1 AND market='KR' AND source IN ('kis','kiwoom')
        AND candle_time >= '202608130900' AND candle_time <= '202608131530'
        AND code NOT IN ({ph})
      GROUP BY code HAVING kis_n>0 AND kw_n>0
    ) x
    """,
    tuple(perm),
)[0]))

print('\npersist + tick density KR 09-15:30 overlap excl perm')
print(dict(q(
    f"""
    SELECT
      COUNT(*) n,
      ROUND(SUM(kis_cm)/NULLIF(SUM(kis_tm),0),3) kis_persist,
      ROUND(SUM(kw_cm)/NULLIF(SUM(kw_tm),0),3) kw_persist,
      ROUND(AVG(kis_tm),1) avg_kis_tm,
      ROUND(AVG(kw_tm),1) avg_kw_tm,
      ROUND(AVG(kis_cm),1) avg_kis_cm,
      ROUND(AVG(kw_cm),1) avg_kw_cm,
      SUM(kis_ticks) kis_ticks,
      SUM(kw_ticks) kw_ticks,
      ROUND(SUM(kw_ticks)/NULLIF(SUM(kis_ticks),0),3) tick_ratio,
      ROUND(SUM(kw_ticks)/NULLIF(SUM(kw_tm),0),1) kw_tpm,
      ROUND(SUM(kis_ticks)/NULLIF(SUM(kis_tm),0),1) kis_tpm
    FROM (
      SELECT t.code, t.kis_ticks, t.kw_ticks, t.kis_tm, t.kw_tm,
        COALESCE(c.kis_cm,0) kis_cm, COALESCE(c.kw_cm,0) kw_cm
      FROM (
        SELECT code,
          SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END) kis_ticks,
          SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END) kw_ticks,
          COUNT(DISTINCT CASE WHEN source='kis' THEN LEFT(tick_time,12) END) kis_tm,
          COUNT(DISTINCT CASE WHEN source='kiwoom' THEN LEFT(tick_time,12) END) kw_tm
        FROM ws_ticks
        WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
          AND source IN ('kis','kiwoom') AND market='KR' AND code NOT IN ({ph})
        GROUP BY code
        HAVING SUM(CASE WHEN source='kis' THEN 1 ELSE 0 END)>0
           AND SUM(CASE WHEN source='kiwoom' THEN 1 ELSE 0 END)>0
      ) t
      LEFT JOIN (
        SELECT code,
          COUNT(DISTINCT CASE WHEN source='kis' THEN candle_time END) kis_cm,
          COUNT(DISTINCT CASE WHEN source='kiwoom' THEN candle_time END) kw_cm
        FROM ws_candles
        WHERE timeframe=1 AND market='KR' AND source IN ('kis','kiwoom')
          AND candle_time >= '202608130900' AND candle_time <= '202608131530'
          AND code NOT IN ({ph})
        GROUP BY code
      ) c ON t.code=c.code
    ) x
    """,
    (*perm, *perm),
)[0]))

print('\nshared minute density KR 09-15:30 excl perm')
print(dict(q(
    f"""
    SELECT COUNT(*) shared_mins,
      SUM(kis_n) kis_ticks, SUM(kw_n) kw_ticks,
      ROUND(SUM(kw_n)/NULLIF(SUM(kis_n),0),3) ratio,
      ROUND(AVG(kis_n),2) avg_kis, ROUND(AVG(kw_n),2) avg_kw
    FROM (
      SELECT code, m,
        SUM(CASE WHEN source='kis' THEN n ELSE 0 END) kis_n,
        SUM(CASE WHEN source='kiwoom' THEN n ELSE 0 END) kw_n
      FROM (
        SELECT source, code, LEFT(tick_time,12) m, COUNT(*) n
        FROM ws_ticks
        WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
          AND source IN ('kis','kiwoom') AND market='KR' AND code NOT IN ({ph})
        GROUP BY source, code, LEFT(tick_time,12)
      ) z GROUP BY code, m HAVING kis_n>0 AND kw_n>0
    ) x
    """,
    tuple(perm),
)[0]))

# rest 1m KR hours excl perm
print('\nrest 1m KR hours excl perm')
print(dict(q(
    f"""
    SELECT COUNT(*) n, COUNT(DISTINCT code) codes,
      ROUND(COUNT(*)/COUNT(DISTINCT code),1) avg_bars
    FROM ws_candles
    WHERE timeframe=1 AND source='rest' AND market='KR'
      AND candle_time >= '202608130900' AND candle_time <= '202608131530'
      AND code NOT IN ({ph})
    """,
    tuple(perm),
)[0]))

# perm tick/candle for completeness
print('\nperm KR ticks/candles')
rows = q(
    f"""
    SELECT source, COUNT(*) n FROM ws_ticks
    WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
      AND source IN ('kis','kiwoom') AND code IN ({ph}) GROUP BY source
    """,
    tuple(perm),
)
print('ticks', [dict(r) for r in rows])
rows = q(
    f"""
    SELECT source, COUNT(*) n FROM ws_candles
    WHERE timeframe=1 AND market='KR' AND source IN ('kis','kiwoom')
      AND candle_time >= '202608130900' AND candle_time <= '202608131530'
      AND code IN ({ph}) GROUP BY source
    """,
    tuple(perm),
)
print('1m', [dict(r) for r in rows])
PY

Clean fair KR hours persist and density


영구구독 7종목이 KIS 틱의 절반을 차지했고, 1분봉 차이는 틱 밀도가 아니라 봉 저장률 차이입니다. 캔버스를 공정 비교로 갱신합니다.


Write

import {
  BarChart,
  Callout,
  Card,
  CardBody,
  CardHeader,
  Grid,
  H1,
  H2,
  Stack,
  Stat,
  Table,
  Text,
} from "cursor/canvas";

/**
 * 공정 비교: 영구구독 제외 · 국장 09:00-15:30 · 키움 vs KIS
 * 2026-08-13 · kis_quant_db
 */
export default function TodayTickObDbInventory() {
  return (
    <Stack gap={20}>
      <Stack gap={6}>

공정 비교 — 영구구독 제외

        <Text tone="secondary">
          2026-08-13 · 국장 09:00–15:30 · KR 영구구독 7종목 제외
          (005930, 000660, 069500, 229200, 379810, 005380, 466930)
        </Text>
      </Stack>

      <Callout tone="danger">
        틱 5배는 오늘 DB에서 성립하지 않음. 같은 종목·같은 1분 기준 키움/KIS =
        1.40× (분당 33틱 vs 24틱). 1분봉이 차이나는 이유는 틱이 아니라 저장률:
        틱이 있는 분 중 KIS 68% vs 키움 30%만 1분봉으로 남김.
      </Callout>

      <Grid columns={4} gap={12}>
        <Stat value="1.26×" label="겹침종목 틱 합계" tone="info" />
        <Stat value="1.40×" label="같은 1분의 틱 밀도" tone="info" />
        <Stat value="0.39×" label="겹침종목 1분봉 수" tone="warning" />
        <Stat value="5× 아님" label="키움 vs KIS 틱" tone="danger" />
      </Grid>

0) 영구구독이 왜곡하던 것

      <Callout tone="warning">
        영구구독은 LS RAM으로 빠져 KIS/키움 슬롯에서 제외됨. 그래서 삼성·하이닉스
        등 7종목은 오늘 키움 틱 0건, KIS만 286,305틱(KIS 전체의 56%). 이걸 넣고
        비교하면 KIS가 더 많아 보임.
      </Callout>

1) 틱 — 영구구독 뺀 공정 숫자

      <Text tone="secondary" size="small">
        Source: ws_ticks · market=KR · 09:00–15:30 · 영구구독 제외. 종목별 중앙값은
        이전과 동일 1.08× (≥5×는 15종목뿐).
      </Text>

2) 1분봉이 차이나는 진짜 이유

      <Grid columns={3} gap={12}>
        <Card>
          <CardHeader>공정 1분봉 건수</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>KIS 6,692봉 / 295종</Text>
              <Text>키움 2,628봉 / 294종</Text>
              <Text>겹침 276종: 24.1 vs 9.3봉</Text>
            </Stack>
          </CardBody>
        </Card>
        <Card>
          <CardHeader>틱분 대비 봉 저장률</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>KIS 67.9% (틱 있는 분의 68%)</Text>
              <Text>키움 29.5% (같은 분의 30%)</Text>
              <Text>틱분은 거의 같음 (31 vs 28)</Text>
            </Stack>
          </CardBody>
        </Card>
        <Card>
          <CardHeader>REST 1분 (참고)</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>88,237봉 / 327종</Text>
              <Text>종목당 평균 270봉</Text>
              <Text>WS 소스와 별개 웜업/보정</Text>
            </Stack>
          </CardBody>
        </Card>
      </Grid>
      <Callout tone="info">
        1분봉 격차(KIS가 약 2.6배)는 「키움이 시세를 덜 준다」가 아님. 틱이 찍힌
        분은 양쪽이 비슷한데, 키움은 그 중 30%만 ws_candles에 남기고 KIS는 68%를
        남김. 구독 유지 시간·flush 정책 차이.
      </Callout>

      <BarChart
        title="시간대별 1분봉 건수 (영구구독·해외 제외, KR)"
        height={220}
        categories={["09시", "10시", "11시", "12시", "13시", "14시", "15시"]}
        series={[
          { name: "KIS 1분봉", data: [769, 1024, 937, 1021, 1154, 1319, 518] },
          { name: "키움 1분봉", data: [499, 692, 375, 308, 218, 378, 379] },
        ]}
      />
      <Text tone="secondary" size="small">
        Source: ws_candles timeframe=1 · 영구구독 제외(US 심야 봉 미포함). 키움/KIS
        봉 비율은 시간대 0.19~0.73.
      </Text>

3) KIS로 바꿀까 — 오늘 데이터 기준

      <Table
        headers={["질문", "답"]}
        columnAlign={["left", "left"]}
        rows={[
          [
            "키움 틱이 5배인가?",
            "아니오. 공정 비교 1.26×(합계) ~ 1.40×(같은 1분).",
          ],
          [
            "1분봉은?",
            "KIS가 더 많음(약 2.5×). 시세량이 아니라 DB 저장률 차이.",
          ],
          [
            "호가 시계열",
            "오늘 kiwoom_0d만 282,119건. KIS 호가 source 없음.",
          ],
          [
            "전환 시 잃는 것",
            "키움 호가 연속저장·분당 틱 소폭(1.4×) 우세.",
          ],
        ]}
      />
    </Stack>
  );
}


맞습니다. **영구구독을 빼고 다시 세면** 그림이 이렇게 바뀝니다.

영구구독 KR 7종목(삼성·하이닉스·KODEX200·코스닥150 ETF·DBBAND 등)은 키움 슬롯에서 빠져 **키움 틱 0건**, KIS만 **286,305틱**(KIS 전체의 56%)입니다. 이걸 넣으면 KIS가 많아 보이는 착시가 납니다.

### 공정 비교 (영구구독 제외 · 국장 09:00–15:30)

| 지표 | KIS | 키움 | 키움/KIS |
|------|-----|------|----------|
| 틱 합계 | 226,529 | 284,879 | **1.26×** |
| 같은 종목+같은 1분 (7,920분) | 분당 23.7틱 | 분당 33.2틱 | **1.40×** |
| 1분봉 건수 | 6,692 | 2,628 | **0.39×** (KIS가 더 많음) |
| 틱이 있는 분 → 1분봉 저장 | **67.9%** | **29.5%** | — |

**틱 5배는 오늘 DB에서 아닙니다.** 가장 공정한 기준(같은 종목, 같은 1분)도 **1.4배**입니다.

**1분봉이 크게 차이나는 이유**는 시세를 덜 받아서가 아닙니다. 틱이 찍힌 분은 종목당 평균 31분 vs 28분으로 비슷한데, 키움은 그중 **30%만** `ws_candles`에 남기고 KIS는 **68%**를 남깁니다. 구독 유지·flush 정책 차이입니다. (REST 1분은 별도로 88,237봉 — WS 비교와 무관)

KIS로 바꾸면 틱은 거의 같고, **키움 호가 연속저장(오늘 28만 건)** 은 잃습니다. 캔버스도 이 공정 비교로 바꿔 두었습니다.


<timestamp>Thursday, Aug 13, 2026, 6:42 PM (UTC+9)</timestamp>
<user_query>
키움 1분봉에 왜 30퍼만 저장하는거지? 갭보정도 키움이 하는데
</user_query>


키움 1분봉 저장률이 ~30%인 원인을 코드·DB로 확인합니다. 갭보정이 `source=kiwoom`이 아니라 `rest`/`kw_rest`로 들어가는지도 같이 보겠습니다.


Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
perm = ['000660','005380','005930','069500','229200','379810','466930']
ph = ','.join(['%s']*len(perm))

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

# For each source: tick-minutes classified by whether candle exists as same source / rest / neither
print('=== tick-minute fate (KR 09-1530 excl perm) ===')
for src in ('kis', 'kiwoom'):
    row = q(
        f"""
        SELECT
          COUNT(*) tick_mins,
          SUM(CASE WHEN has_same=1 THEN 1 ELSE 0 END) has_same_src,
          SUM(CASE WHEN has_same=0 AND has_rest=1 THEN 1 ELSE 0 END) rest_only,
          SUM(CASE WHEN has_same=0 AND has_rest=0 AND has_other=1 THEN 1 ELSE 0 END) other_only,
          SUM(CASE WHEN has_same=0 AND has_rest=0 AND has_other=0 THEN 1 ELSE 0 END) missing
        FROM (
          SELECT t.code, t.m,
            MAX(CASE WHEN c.source=%s THEN 1 ELSE 0 END) has_same,
            MAX(CASE WHEN c.source='rest' THEN 1 ELSE 0 END) has_rest,
            MAX(CASE WHEN c.source IS NOT NULL AND c.source NOT IN (%s,'rest') THEN 1 ELSE 0 END) has_other
          FROM (
            SELECT code, LEFT(tick_time,12) m
            FROM ws_ticks
            WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
              AND source=%s AND market='KR' AND code NOT IN ({ph})
            GROUP BY code, LEFT(tick_time,12)
          ) t
          LEFT JOIN ws_candles c
            ON c.code=t.code AND c.candle_time=t.m AND c.timeframe=1 AND c.market='KR'
          GROUP BY t.code, t.m
        ) x
        """,
        (src, src, src, *perm),
    )[0]
    print(src, dict(row))
    n = int(row['tick_mins'])
    for k in ('has_same_src','rest_only','other_only','missing'):
        v = int(row[k])
        print(f"  {k}: {v} ({100*v/n:.1f}%)")

# sample missing kiwoom minutes: tick count, whether next minute has ticks
print('\n=== sample missing kiwoom tick-mins ===')
rows = q(
    f"""
    SELECT t.code, t.m, t.n_ticks,
      (SELECT COUNT(*) FROM ws_candles c WHERE c.code=t.code AND c.candle_time=t.m AND c.timeframe=1) any_candle,
      (SELECT GROUP_CONCAT(DISTINCT c.source) FROM ws_candles c WHERE c.code=t.code AND c.candle_time=t.m AND c.timeframe=1) sources
    FROM (
      SELECT code, LEFT(tick_time,12) m, COUNT(*) n_ticks
      FROM ws_ticks
      WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
        AND source='kiwoom' AND market='KR' AND code NOT IN ({ph})
      GROUP BY code, LEFT(tick_time,12)
    ) t
    LEFT JOIN ws_candles k
      ON k.code=t.code AND k.candle_time=t.m AND k.timeframe=1 AND k.source='kiwoom'
    WHERE k.id IS NULL
    ORDER BY t.n_ticks DESC
    LIMIT 15
    """,
    tuple(perm),
)
for r in rows:
    print(dict(r))

# distribution of missing: by hour, by n_ticks bucket
print('\n=== missing kiwoom by tick-count bucket ===')
rows = q(
    f"""
    SELECT
      CASE
        WHEN n_ticks=1 THEN '1'
        WHEN n_ticks<=5 THEN '2-5'
        WHEN n_ticks<=20 THEN '6-20'
        WHEN n_ticks<=50 THEN '21-50'
        ELSE '51+'
      END bucket,
      COUNT(*) mins
    FROM (
      SELECT t.code, t.m, t.n_ticks
      FROM (
        SELECT code, LEFT(tick_time,12) m, COUNT(*) n_ticks
        FROM ws_ticks
        WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
          AND source='kiwoom' AND market='KR' AND code NOT IN ({ph})
        GROUP BY code, LEFT(tick_time,12)
      ) t
      LEFT JOIN ws_candles k
        ON k.code=t.code AND k.candle_time=t.m AND k.timeframe=1 AND k.source='kiwoom'
      WHERE k.id IS NULL
    ) x
    GROUP BY bucket ORDER BY bucket
    """,
    tuple(perm),
)
for r in rows:
    print(dict(r))

print('\n=== same for kis missing ===')
rows = q(
    f"""
    SELECT
      CASE
        WHEN n_ticks=1 THEN '1'
        WHEN n_ticks<=5 THEN '2-5'
        WHEN n_ticks<=20 THEN '6-20'
        WHEN n_ticks<=50 THEN '21-50'
        ELSE '51+'
      END bucket,
      COUNT(*) mins
    FROM (
      SELECT t.code, t.m, t.n_ticks
      FROM (
        SELECT code, LEFT(tick_time,12) m, COUNT(*) n_ticks
        FROM ws_ticks
        WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
          AND source='kis' AND market='KR' AND code NOT IN ({ph})
        GROUP BY code, LEFT(tick_time,12)
      ) t
      LEFT JOIN ws_candles k
        ON k.code=t.code AND k.candle_time=t.m AND k.timeframe=1 AND k.source='kis'
      WHERE k.id IS NULL
    ) x
    GROUP BY bucket ORDER BY bucket
    """,
    tuple(perm),
)
for r in rows:
    print(dict(r))
PY

Classify tick-minutes vs candle fate


핵심 단서: 키움 틱이 있는 분의 60%는 봉이 `source=kis`로만 남아 있습니다. 유니크 키·upsert 규칙을 확인합니다.

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()
print('indexes', [dict(r) for r in q('SHOW INDEX FROM ws_candles')])
print('create:')
print(q('SHOW CREATE TABLE ws_candles')[0]['Create Table'])
PY

Show ws_candles unique keys


유니크 키가 `source` 없이 `(code, timeframe, candle_time)`라서, 키움 봉이 KIS에 밀린 것으로 보입니다. 분포를 한 번 더 확인합니다.

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
perm = ['000660','005380','005930','069500','229200','379810','466930']
ph = ','.join(['%s']*len(perm))

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

print('=== kiwoom tick-min → surviving candle source ===')
rows = q(
    f"""
    SELECT COALESCE(surv_src,'(none)') surv_src, COUNT(*) n
    FROM (
      SELECT t.code, t.m,
        (SELECT c.source FROM ws_candles c
         WHERE c.code=t.code AND c.candle_time=t.m AND c.timeframe=1 AND c.market='KR'
         LIMIT 1) surv_src
      FROM (
        SELECT code, LEFT(tick_time,12) m
        FROM ws_ticks
        WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
          AND source='kiwoom' AND market='KR' AND code NOT IN ({ph})
        GROUP BY code, LEFT(tick_time,12)
      ) t
    ) x
    GROUP BY surv_src ORDER BY n DESC
    """,
    tuple(perm),
)
for r in rows:
    print(dict(r))

# ANY candle coverage for kiwoom tick mins
print('\nany candle coverage', dict(q(
    f"""
    SELECT
      COUNT(*) tick_mins,
      SUM(CASE WHEN surv IS NOT NULL THEN 1 ELSE 0 END) has_candle,
      ROUND(100*SUM(CASE WHEN surv IS NOT NULL THEN 1 ELSE 0 END)/COUNT(*),1) pct
    FROM (
      SELECT t.code, t.m,
        (SELECT 1 FROM ws_candles c
         WHERE c.code=t.code AND c.candle_time=t.m AND c.timeframe=1 LIMIT 1) surv
      FROM (
        SELECT code, LEFT(tick_time,12) m FROM ws_ticks
        WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
          AND source='kiwoom' AND market='KR' AND code NOT IN ({ph})
        GROUP BY code, LEFT(tick_time,12)
      ) t
    ) x
    """,
    tuple(perm),
)[0]))

print('\nkis tick-min surviving source')
rows = q(
    f"""
    SELECT COALESCE(surv_src,'(none)') surv_src, COUNT(*) n
    FROM (
      SELECT t.code, t.m,
        (SELECT c.source FROM ws_candles c
         WHERE c.code=t.code AND c.candle_time=t.m AND c.timeframe=1 AND c.market='KR'
         LIMIT 1) surv_src
      FROM (
        SELECT code, LEFT(tick_time,12) m FROM ws_ticks
        WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
          AND source='kis' AND market='KR' AND code NOT IN ({ph})
        GROUP BY code, LEFT(tick_time,12)
      ) t
    ) x
    GROUP BY surv_src ORDER BY n DESC
    """,
    tuple(perm),
)
for r in rows:
    print(dict(r))

# schema drift note
print('\ncode DDL expects source in unique; live DB does not')
print('DDL file unique includes source?', 'source),' in open('database.py').read().split('CREATE TABLE IF NOT EXISTS ws_candles')[1][:800])
PY

Surviving candle source for kiwoom tick-mins

StrReplace

      <Callout tone="danger">
        틱 5배는 오늘 DB에서 성립하지 않음. 같은 종목·같은 1분 기준 키움/KIS =
        1.40× (분당 33틱 vs 24틱). 1분봉이 차이나는 이유는 틱이 아니라 저장률:
        틱이 있는 분 중 KIS 68% vs 키움 30%만 1분봉으로 남김.
      </Callout>

      <Callout tone="danger">
        틱 5배는 성립하지 않음(같은 1분 1.40×). 1분봉 「키움 30%」는 미저장이
        아님 — ws_candles UNIQUE가 source 없이 (code,tf,time) 한 줄만 허용하고
        freeze 때문에 먼저 확정된 kis/rest 라벨이 남음. 키움 틱분 중 실제 봉
        존재율 ≈ 99.6%.
      </Callout>


2) 1분봉 「키움 30%」의 진짜 이유

      <Callout tone="warning">
        갭보정은 키움 REST가 맞음. 다만 DB에는 source=rest 로 들어감 (source=kiwoom
        아님). 그리고 UNIQUE uq_candle=(code,timeframe,candle_time) 에 source가
        없어서 분당 1행만 생존. freeze ON이면 먼저 확정된 쪽(대개 kis) 라벨 고정.
      </Callout>

      <Text tone="secondary" size="small">
        Source: ws_ticks.source=kiwoom 분 × ws_candles 조인 · 영구구독 제외 ·
        09:00–15:30. 봉 존재율 99.6%. 「30%」는 source=kiwoom 필터의 착시.
      </Text>
      <Grid columns={3} gap={12}>
        <Card>
          <CardHeader>공정 1분봉 건수(라벨)</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>KIS 라벨 6,692</Text>
              <Text>키움 라벨 2,628</Text>
              <Text>의미: 승자 라벨 분포</Text>
            </Stack>
          </CardBody>
        </Card>
        <Card>
          <CardHeader>갭보정</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>키움 REST → source=rest</Text>
              <Text>이미 kis면 freeze로 유지</Text>
              <Text>구멍만 insert</Text>
            </Stack>
          </CardBody>
        </Card>
        <Card>
          <CardHeader>스키마</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>uq_candle에 source 없음</Text>
              <Text>분당 소스 병행 불가</Text>
              <Text>의도적 단일 시리즈</Text>
            </Stack>
          </CardBody>
        </Card>
      </Grid>

2) 1분봉이 차이나는 진짜 이유

      <Grid columns={3} gap={12}>
        <Card>
          <CardHeader>공정 1분봉 건수</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>KIS 6,692봉 / 295종</Text>
              <Text>키움 2,628봉 / 294종</Text>
              <Text>겹침 276종: 24.1 vs 9.3봉</Text>
            </Stack>
          </CardBody>
        </Card>
        <Card>
          <CardHeader>틱분 대비 봉 저장률</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>KIS 67.9% (틱 있는 분의 68%)</Text>
              <Text>키움 29.5% (같은 분의 30%)</Text>
              <Text>틱분은 거의 같음 (31 vs 28)</Text>
            </Stack>
          </CardBody>
        </Card>
        <Card>
          <CardHeader>REST 1분 (참고)</CardHeader>
          <CardBody>
            <Stack gap={8}>
              <Text>88,237봉 / 327종</Text>
              <Text>종목당 평균 270봉</Text>
              <Text>WS 소스와 별개 웜업/보정</Text>
            </Stack>
          </CardBody>
        </Card>
      </Grid>
      <Callout tone="info">
        1분봉 격차(KIS가 약 2.6배)는 「키움이 시세를 덜 준다」가 아님. 틱이 찍힌
        분은 양쪽이 비슷한데, 키움은 그 중 30%만 ws_candles에 남기고 KIS는 68%를
        남김. 구독 유지 시간·flush 정책 차이.
      </Callout>


**키움이 30%만 저장하는 게 아닙니다.** `source=kiwoom`으로 **라벨이 남은** 비율이 30%인 겁니다.

### 무슨 일이냐면

1. **갭보정은 키움 REST가 맞음** → DB에는 `source=rest`로 들어감 (`kiwoom` 아님).
2. **`ws_candles` UNIQUE = `(code, timeframe, candle_time)`** → source 없음 → **같은 1분에 행 1개만**.
3. **`WS_CANDLE_FREEZE_ON_CONFIRM=true`** → 먼저 확정된 쪽 OHLCV·source 고정. 나중에 키움이 와도 덮지 않음.

그래서 키움 틱이 있는 분(8,909분)의 실제 운명:

| 살아남은 봉 source | 분수 | 비율 |
|---|---|---|
| **kis** | 5,374 | 60% |
| **kiwoom** | 2,605 | 29% |
| **rest** (갭보정) | 893 | 10% |
| **없음** | 34 | **0.4%** |

→ **봉 존재율 99.6%**. 안 쌓인 게 아니라, 양쪽 틱이 오면 대개 KIS가 먼저 확정되어 `kis`로 표기됩니다.

초등학생 버전:  alike 공책에 **한 칸에 한 줄만** 쓰게 해 둔 상태. 키움·KIS·갭보정이 같은 분에 오면 **먼저 쓴 사람 이름**만 남고, 나머지는 “저장 실패”처럼 보입니다.

코드를 고치려면(예: UNIQUE에 source 포함해 병행 저장) 정합·백테 영향이 커서 **선승인**이 필요합니다. 지금은 원인만 확정했습니다.

<reproduction_steps>
1. 아래처럼 키움 틱이 있는 1분에 실제 남은 `ws_candles.source` 분포를 조회한다 (영구구독 7종 제외, 09:00–15:30).
2. `source=kiwoom`만 세면 ~30%, 아무 source든 있으면 ~99.6%인지 확인한다.
3. `SHOW INDEX FROM ws_candles`로 `uq_candle`에 `source`가 없는지 확인한다.
4. Press Proceed/Mark as fixed when done.
</reproduction_steps>


<timestamp>Thursday, Aug 13, 2026, 6:56 PM (UTC+9)</timestamp>
<user_query>
아 이거 백테용이라 옵투나랑 실매랑 정합을 맞추려면 설계 잘해야되는데 지금은 정합성이 얼마정도지? 기본으로 돌리면 실매랑 똑같이 도는걸로 알고있는데 실매쪽도 확정봉으로 도니까 옵투나랑 똑같이 가는게 맞긴할거같은데 이게 먼저 확정되는게 우선이니까 이게 아 내머리로는 이해가 잘 안되네 ohcl 이건 내가 가장 싫어하는건데 룰에도 명시가 되어잇고 이 ohcl은 그런 병신같은건 아닌거처럼 보이긴 하는데 설명 보니까 흠 난 이해시켜줘
</user_query>


정합이 실제로 어떻게 맞춰지는지 `docs/정합성.md`와 백테·실매의 봉 소스 선택 경로를 확인한 뒤, OHLC 폴백과 헷갈리지 않게 쉽게 설명하겠습니다.


핵심만 말하면, **지금 헷갈리는 “먼저 확정된 쪽이 이긴다”는 OHLC 폴백이 아닙니다.** 룰에 적어 둔 그 병신짓과는 다른 층입니다.

---

### 1) 당신이 싫어하는 OHLC vs 지금 이야기

| | **OHLC 폴백 (금지)** | **지금 이야기 (freeze / 한 칸)** |
|---|---|---|
| 뭐하냐 | 틱 청산인데 **봉의 high/low로** 손절·익절·고점을 채움 | 1분봉 OHLCV를 **한 번 확정하면 안 키움** |
| 왜 싫냐 | 미래 고점 look-ahead, 유령 청산 → **실매≠백테** | REST/갭이 나중에 volume을 키워 **같은 분 숫자가 변함** |
| 지금 | 엔진이 무시하도록 잠금 | `WS_CANDLE_FREEZE_ON_CONFIRM=true` |

초등학생 버전:
- OHLC 폴백 = “틱으로 놀다 말고, **봉 그림으로 몰래 채점**”
- freeze = “시험지 **한 번 쓴 답을 나중에 고치지 마**”

---

### 2) 실매 · 백테 · Optuna가 “같게” 도는 구조

설계 한 줄 (`docs/정합성.md`):

```
실매가 그날 확정해서 DB에 남긴 1분봉 = 웹백테/Optuna가 읽는 봉
```

- **엔진식** (신호 T−1, 진입 T, 틱 청산, OHLC 폴백 OFF) → 실매·웹·Optuna **같은 코드 경로**가 기본.
- **입력 데이터** → 전부 `ws_candles` + `ws_ticks`를 봄. Optuna가 실시간 WS에 붙지 않음.

그래서 “기본으로 돌리면 실매랑 똑같이”가 **의도**는 맞습니다.
같은 DB 봉을 재생하는 구조이기 때문입니다.

정합 “몇 %”를 숫자로 단정하긴 어렵고, 문서도 **깨끗한 장일 하루로 volume이 안 커지는지 → 웹백테 1회 대조**가 합격 기준입니다. freeze는 이미 켜져 있고, **그날 검증을 닫아야 “정합 완료”**라고 말하게 되어 있습니다.

---

### 3) “먼저 확정된 쪽이 이긴다”를 왜 만들었나

DB는 **같은 종목·같은 1분에 행이 딱 1개**입니다.
(`UNIQUE (code, timeframe, candle_time)` — source 없음)

키움 WS, KIS WS, 키움 갭보정(REST)이 **같은 분**에 다 오면:

1. 누가 먼저 “이 분 끝났다”고 DB에 넣으면 → 그 OHLCV가 **동결**
2. 나중에 오는 쪽은 **구멍만** 채우고, 이미 있는 분은 **안 덮음**

이게 “키움이 30%만 저장”처럼 보였던 이유입니다.
안 저장한 게 아니라, **칸에 이미 KIS(또는 rest) 라벨이 있어서** `source=kiwoom`으로 안 보이는 것.
실제로는 그 분의 봉이 **99.6% 존재**합니다.

초등학생 버전:
공책 **한 칸에 한 줄만**. 키움·한투·선생님(갭보정)이 동시에 써도 **먼저 쓴 글씨만 남김**.
나중에 와서 “거래량 더 커졌는데?” 하고 지우는 걸 막은 겁니다. (그게 예전에 실매≠백테를 만들던 본체)

---

### 4) 머리로 안 풀리던 포인트 — 실매 RAM vs DB

여기가 진짜 헷갈리는 지점입니다.

**장중 실매**는 DB만 안 보고, RAM에 소스별로 따로 들고 있습니다.

- 기본 `LIVE_TICK_PROVIDER=kiwoom`이면: **같은 분이면 키움 봉을 우선**해서 매수 판단.
- DB에는 그래도 **한 칸뿐**이라, 레이스에서 KIS가 먼저면 라벨은 `kis`로 남을 수 있음.

**백테/Optuna**는 DB의 그 **한 칸**을 읽습니다.

그래서:

- **엔진·규칙** 정합 → 맞음 (T−1/T, freeze, 틱청산, OHLC폴백 금지)
- **“그날 그 분의 OHLCV 숫자”** → 설계상 DB = 백테 진실.
  장중 순간에는 RAM에서 키움을 선호했을 수 있어, **라벨/레이스** 때문에 미세하게 다를 여지는 있음.
  다만 그건 “OHLC로 청산 조작”이 아니라 **한 시리즈만 남기는 저장 설계** 쪽 이슈입니다.

갭보정은 키움 REST가 맞는데, DB에는 `source=rest`로 들어갑니다.
이미 칸이 차 있으면 freeze 때문에 **덮지 않고**, 비어 있는 분만 채웁니다.

---

### 5) 정리 — 지금 상태를 한 문장으로

- **싫어하는 OHLC 폴백** → 아님. 청산에 봉 고저를 쓰는 짓이 아님.
- **지금** → “확정 봉은 안 키운다 + DB는 분당 1행”으로 **실매가 남긴 시리즈 = Optuna 입력**을 맞추려는 설계.
- **「키움 30%」** → 미저장이 아니라 **승자 라벨 통계**.
- **기본 Optuna ≈ 실매** → 엔진은 같고, 봉은 그 DB 시리즈를 재생.
  “완벽한 브로커 소스 일치”까지는 UNIQUE 없이 source 병행이 안 되는 한, **한 칸의 승자**가 진실이 됩니다.

원하시면 다음으로 **실매 RAM(키움 우선) vs DB 승자 행**이 같은 날 OHLCV가 얼마나 같은지 숫자로만 재면, “정합이 얼마쯤인지”를 감으로가 아니라 표로 줄 수 있습니다. (설계 변경은 승인 후.)

<reproduction_steps>
1. `docs/정합성.md` §0·§3만 읽고, freeze = “확정 후 덮지 않음”인지 확인한다.
2. `SHOW INDEX FROM ws_candles`로 `uq_candle`이 `(code,timeframe,candle_time)`인지 확인한다 (source 없음 = 분당 1행).
3. 오늘 키움 틱이 있는 분 중 `source=kiwoom`만 세지 말고, 아무 source든 봉이 있는지 세어 존재율이 ~99%대인지 본다.
4. Press Proceed/Mark as fixed when done.
</reproduction_steps>


<timestamp>Thursday, Aug 13, 2026, 8:41 PM (UTC+9)</timestamp>
<user_query>
오케이 표롤 만들어줘봐 그리고 궁금한건 거래소에서 증권사로 각각 넘어오는게 증권사별로 속도가 다르잖아 이건 또 값도 달라버리니까 먼저오는걸로 하는게 맞는건가 지금 매매는  kis로 하고 있는데 거래소가 진짜 값이긴해서 빠른게 맞긴한데 또 매매할때 가격은 kis 기준이니까 이거에 대한 대답이랑 또 rest는 무조건 kiwoom인데 이거는 rest라는건 다른 열로 빼고 ws인지 rest인지를 넣는 컬럼 그리고 소스에는 증권사 kiwoom 으로 넣으면 어때? 뭐 많이 바꿔야되나 코드를 이거 뭐 굳이 바꿀 필요없나
</user_query>


표는 DB로 실제 숫자를 뽑고, ‘먼저 온 쪽’ vs KIS 체결가, REST 컬럼 분리안은 코드 영향부터 확인하겠습니다.


Shell

cd /home/hoon/kis_bot && python3 -m py_compile - <<'PY'
print('skip')
PY
python3 - <<'PY'
from database import TradeDB
from kis_trader.utils.env import get_env_from_db

db = TradeDB()
perm = ['000660','005380','005930','069500','229200','379810','466930']
ph = ','.join(['%s']*len(perm))

def q(sql, params=None):
    return db.conn.execute(sql, params or ()).fetchall()

print('LIVE_TICK_PROVIDER', get_env_from_db('LIVE_TICK_PROVIDER', 'kiwoom'))
print('CANDLE_SOURCE', repr(get_env_from_db('CANDLE_SOURCE', '')))
print('WS_CANDLE_FREEZE_ON_CONFIRM', get_env_from_db('WS_CANDLE_FREEZE_ON_CONFIRM', 'true'))

# Rebuild per-code-minute OHLC from ticks via min/max id
print('\n=== build tick ohlc (this may take a bit) ===')

# Drop if leftover from agent (unlikely)
try:
    db.conn.execute('DROP TEMPORARY TABLE IF EXISTS tmp_tick_ohlc')
except Exception as e:
    print('drop', e)

db.conn.execute(f"""
CREATE TEMPORARY TABLE tmp_tick_ohlc AS
SELECT
  a.source,
  a.code,
  a.m,
  o.price AS o,
  a.h,
  a.l,
  c.price AS c,
  a.v,
  a.min_recv,
  a.max_recv,
  a.n
FROM (
  SELECT source, code, LEFT(tick_time,12) m,
    MIN(id) min_id, MAX(id) max_id,
    MAX(price) h, MIN(price) l, SUM(volume) v,
    MIN(recv_ts) min_recv, MAX(recv_ts) max_recv,
    COUNT(*) n
  FROM ws_ticks
  WHERE tick_time >= '20260813090000' AND tick_time <= '20260813153000'
    AND source IN ('kis','kiwoom') AND market='KR'
    AND code NOT IN ({ph})
  GROUP BY source, code, LEFT(tick_time,12)
) a
JOIN ws_ticks o ON o.id = a.min_id
JOIN ws_ticks c ON c.id = a.max_id
""", tuple(perm))

print('tmp rows', q('SELECT COUNT(*) n FROM tmp_tick_ohlc')[0]['n'])
print('by src', [dict(r) for r in q('SELECT source, COUNT(*) n FROM tmp_tick_ohlc GROUP BY source')])

print('\n=== shared minutes: who faster (min_recv) + OHLC match ===')
print(dict(q("""
SELECT
  COUNT(*) shared,
  SUM(CASE WHEN k.min_recv < s.min_recv THEN 1 ELSE 0 END) kw_recv_first,
  SUM(CASE WHEN s.min_recv < k.min_recv THEN 1 ELSE 0 END) kis_recv_first,
  SUM(CASE WHEN s.min_recv = k.min_recv THEN 1 ELSE 0 END) recv_tie,
  SUM(CASE WHEN k.c = s.c THEN 1 ELSE 0 END) close_eq,
  SUM(CASE WHEN k.o=s.o AND k.h=s.h AND k.l=s.l AND k.c=s.c THEN 1 ELSE 0 END) ohlc_eq,
  SUM(CASE WHEN ABS(k.c-s.c)/NULLIF(s.c,0) > 0.001 THEN 1 ELSE 0 END) close_gt_10bp,
  SUM(CASE WHEN ABS(k.c-s.c)/NULLIF(s.c,0) > 0.003 THEN 1 ELSE 0 END) close_gt_30bp
FROM tmp_tick_ohlc k
JOIN tmp_tick_ohlc s
  ON k.code=s.code AND k.m=s.m
WHERE k.source='kiwoom' AND s.source='kis'
""")[0]))

print('\n=== DB row vs reconstructed (shared mins only) ===')
print(dict(q("""
SELECT
  COUNT(*) n,
  SUM(CASE WHEN d.source='kis' THEN 1 ELSE 0 END) db_kis,
  SUM(CASE WHEN d.source='kiwoom' THEN 1 ELSE 0 END) db_kw,
  SUM(CASE WHEN d.source='rest' THEN 1 ELSE 0 END) db_rest,
  SUM(CASE WHEN d.source='kw_rest' THEN 1 ELSE 0 END) db_kw_rest,
  SUM(CASE WHEN d.close = k.c THEN 1 ELSE 0 END) db_eq_kw_close,
  SUM(CASE WHEN d.close = s.c THEN 1 ELSE 0 END) db_eq_kis_close,
  SUM(CASE WHEN d.close = k.c AND d.close = s.c THEN 1 ELSE 0 END) all3_eq,
  SUM(CASE WHEN d.close = k.c AND d.close <> s.c THEN 1 ELSE 0 END) db_kw_only,
  SUM(CASE WHEN d.close = s.c AND d.close <> k.c THEN 1 ELSE 0 END) db_kis_only,
  SUM(CASE WHEN d.close <> k.c AND d.close <> s.c THEN 1 ELSE 0 END) db_neither,
  SUM(CASE WHEN d.open=k.o AND d.high=k.h AND d.low=k.l AND d.close=k.c THEN 1 ELSE 0 END) db_eq_kw_ohlc,
  SUM(CASE WHEN d.open=s.o AND d.high=s.h AND d.low=s.l AND d.close=s.c THEN 1 ELSE 0 END) db_eq_kis_ohlc
FROM tmp_tick_ohlc k
JOIN tmp_tick_ohlc s ON k.code=s.code AND k.m=s.m AND k.source='kiwoom' AND s.source='kis'
JOIN ws_candles d ON d.code=k.code AND d.candle_time=k.m AND d.timeframe=1 AND d.market='KR'
""")[0]))

print('\n=== DB source vs whose close it matches ===')
rows = q("""
SELECT
  d.source db_src,
  COUNT(*) n,
  ROUND(100*SUM(d.close=k.c)/COUNT(*),1) pct_kw,
  ROUND(100*SUM(d.close=s.c)/COUNT(*),1) pct_kis,
  ROUND(100*SUM(d.close=k.c AND d.close=s.c)/COUNT(*),1) pct_both,
  ROUND(100*SUM(d.close<>k.c AND d.close<>s.c)/COUNT(*),1) pct_neither
FROM tmp_tick_ohlc k
JOIN tmp_tick_ohlc s ON k.code=s.code AND k.m=s.m AND k.source='kiwoom' AND s.source='kis'
JOIN ws_candles d ON d.code=k.code AND d.candle_time=k.m AND d.timeframe=1 AND d.market='KR'
GROUP BY d.source
""")
for r in rows:
    print(dict(r))

print('\n=== rest rows today KR 1m excl perm: close vs tick reconstruct ===')
print(dict(q(f"""
SELECT COUNT(*) n,
  SUM(CASE WHEN k.c IS NOT NULL THEN 1 ELSE 0 END) has_kw_tick,
  SUM(CASE WHEN s.c IS NOT NULL THEN 1 ELSE 0 END) has_kis_tick,
  SUM(CASE WHEN k.c IS NOT NULL AND d.close=k.c THEN 1 ELSE 0 END) rest_eq_kw,
  SUM(CASE WHEN s.c IS NOT NULL AND d.close=s.c THEN 1 ELSE 0 END) rest_eq_kis
FROM ws_candles d
LEFT JOIN tmp_tick_ohlc k ON k.code=d.code AND k.m=d.candle_time AND k.source='kiwoom'
LEFT JOIN tmp_tick_ohlc s ON s.code=d.code AND s.m=d.candle_time AND s.source='kis'
WHERE d.timeframe=1 AND d.market='KR' AND d.source='rest'
  AND d.candle_time >= '202608130900' AND d.candle_time <= '202608131530'
  AND d.code NOT IN ({ph})
""", tuple(perm))[0]))

print('\n=== median |close kiwoom-kis| / kis among mismatches ===')
print(dict(q("""
SELECT
  COUNT(*) n_mismatch,
  ROUND(AVG(ABS(k.c-s.c)/NULLIF(s.c,0)*100),4) avg_pct,
  ROUND(MAX(ABS(k.c-s.c)/NULLIF(s.c,0)*100),4) max_pct
FROM tmp_tick_ohlc k
JOIN tmp_tick_ohlc s ON k.code=s.code AND k.m=s.m
WHERE k.source='kiwoom' AND s.source='kis' AND k.c <> s.c
""")[0]))
PY

Rebuild tick OHLC vs DB winner stats


Write

import {
  Callout,
  Card,
  CardBody,
  CardHeader,
  Grid,
  H1,
  H2,
  Stack,
  Stat,
  Table,
  Text,
} from "cursor/canvas";

/**
 * 2026-08-13 공정 창: 영구구독 제외 · KR 09:00-15:30
 * 틱으로 1분 OHLC 재구성 vs DB 승자 행
 */
export default function LiveDbCandleParityDesign() {
  return (
    <Stack gap={20}>
      <Stack gap={6}>

실매 RAM vs DB 봉 · 먼저 온 쪽 · REST 라벨

        <Text tone="secondary">
          2026-08-13 · 겹침 7,920분 · LIVE_TICK_PROVIDER=kiwoom · 주문=KIS ·
          freeze ON · CANDLE_SOURCE 빈값(기본 병합)
        </Text>
      </Stack>

      <Callout tone="warning">
        「먼저 온 쪽」은 거래소 진실이 아니라 DB 기록 레이스다. 오늘 같은 1분에
        recv_ts는 KIS가 86% 먼저 찍힘. 실매 신호는 키움 RAM 우선. 체결가는 KIS.
        세 층이 다르다.
      </Callout>

      <Grid columns={4} gap={12}>
        <Stat value="87.1%" label="같은 1분 종가 키움=KIS" tone="success" />
        <Stat value="73.8%" label="OHLC 네 값 전부 같음" />
        <Stat value="81%" label="DB 종가 = 키움틱봉" tone="info" />
        <Stat value="9.1%" label="DB는 KIS, 키움과 불일치" tone="warning" />
      </Grid>

1) 같은 종목·같은 1분 — 키움틱봉 vs KIS틱봉

 0.1%", "789", "10.0%"],
          ["종가 괴리 > 0.3%", "150", "1.9%"],
          ["recv_ts KIS가 먼저", "6,831", "86.2%"],
          ["recv_ts 키움이 먼저", "104", "1.3%"],
          ["recv_ts 동시", "985", "12.4%"],
        ]}
      />
      <Text tone="secondary" size="small">
        Source: ws_ticks min(id)/max(id)로 시가·종가 재구성. recv_ts는 우리 DB에
        찍힌 시각이지 거래소 시각이 아님.
      </Text>

2) DB에 살아남은 한 칸 vs 틱재구성 봉

      <Table
        headers={["DB source 라벨", "분 수", "종가=키움틱", "종가=KIS틱", "둘 다", "둘 다 아님"]}
        columnAlign={["left", "right", "right", "right", "right", "right"]}
        rowTone={["info", "success", "warning", "neutral"]}
        rows={[
          ["kis", "5,374", "87.3%", "99.5%", "87.2%", "0.5%"],
          ["kiwoom", "1,646", "75.2%", "68.0%", "65.6%", "22.4%"],
          ["rest (키움 REST 갭보정)", "863", "53.5%", "50.2%", "48.3%", "44.6%"],
          ["kw_rest", "3", "0%", "0%", "0%", "100%"],
        ]}
      />

3) 세 층 — 빨리 오는 걸 쓰면 되나

      <Grid columns={3} gap={12}>
        <Card>
          <CardHeader>신호 (매수 여부)</CardHeader>
          <CardBody>
            <Text>
              실매는 LIVE_TICK_PROVIDER=키움 RAM. 거래소에 더 가깝다고 해서
              「먼저 DB에 찍힌 증권사」를 쓰면 실매와 어긋남.
            </Text>
          </CardBody>
        </Card>
        <Card>
          <CardHeader>체결가 (주문)</CardHeader>
          <CardBody>
            <Text>
              주문·체결은 KIS. 슬리피지·틱청산 재생은 KIS 틱이 맞음. 키움이
              빨라도 체결 진실은 KIS.
            </Text>
          </CardBody>
        </Card>
        <Card>
          <CardHeader>freeze (덮지 않기)</CardHeader>
          <CardBody>
            <Text>
              「먼저 확정된 칸을 안 고친다」는 REST가 봉을 키우는 걸 막는 장치.
              증권사 경주에서 승자를 고르는 철학이 아님.
            </Text>
          </CardBody>
        </Card>
      </Grid>
      <Callout tone="info">
        답: 빠른 쪽을 진실로 쓰면 안 된다. 신호=키움(지금 설정), 체결=KIS,
        DB freeze=나중에 덮지 않기. 오늘 종가 87%는 어차피 같아서 Optuna 기본
        경로는 대체로 같이 가지만, 약 9%는 DB가 KIS 종가·실매는 키움일 수 있음.
      </Callout>

4) REST를 다른 열로 빼기 — 바꿀까

      <Table
        headers={["안", "UNIQUE", "코드량", "정합 이득"]}
        columnAlign={["left", "left", "left", "left"]}
        rowTone={["success", "warning", "danger"]}
        rows={[
          [
            "안 바꿈 (권장)",
            "분당 1행 유지",
            "0",
            "라벨만 헷갈림. 엔진·freeze는 이미 동작",
          ],
          [
            "source=증권사, channel=ws|rest 라벨만",
            "여전히 분당 1행",
            "중 (kis_ws, bt_candle_source, 갭보정, 백필, UI)",
            "표가 예뻐짐. 승자 레이스는 그대로",
          ],
          [
            "증권사별 행 병행 (UNIQUE에 broker)",
            "분당 키움행+KIS행",
            "큼 (조회·병합·롤업·웹·Optuna 전부)",
            "실매 키움 시리즈를 백테가 고를 수 있음. 지금 승인 전",
          ],
        ]}
      />
      <Text tone="secondary">
        REST는 이미 키움 ka10080. source=rest는 「증권사」가 아니라 「채널」.
        열을 나누는 아이디어는 맞지만, UNIQUE를 안 바꾸면 숫자 정합은 안 좋아짐.
      </Text>
    </Stack>
  );
}


표는 캔버스 `live-db-candle-parity-design`에 넣어 두었습니다. 오늘(영구구독 제외, 겹침 **7,920분**) 숫자 기준입니다.

지금 설정: **시세 신호 = 키움** (`LIVE_TICK_PROVIDER=kiwoom`), **주문·체결 = KIS**.

---

### 1) 표 — 같은 1분에 값이 얼마나 같나

| | 분수 | 비율 |
|---|---|---|
| 종가 키움 = KIS | 6,897 | **87.1%** |
| OHLC 네 값 전부 같음 | 5,845 | 73.8% |
| 종가 괴리 > 0.1% | 789 | 10.0% |
| DB 종가 = 키움틱으로 만든 봉 | — | **81%** |
| DB는 KIS 종가, 키움과 불일치 | 717 | **9.1%** ← 실매 RAM과 Optuna가 갈라질 수 있는 칸 |
| 세 종가 모두 같음 | 6,184 | 78.4% |

recv_ts(우리 DB에 찍힌 시각)는 **KIS가 86% 먼저**. 키움이 먼저인 분은 1.3%뿐입니다.
이건 **거래소가 KIS를 더 빨리 준다**는 증거가 아닙니다. 틱을 디스크에 쓰는 속도·배치 차이일 수 있습니다.

---

### 2) 빠른 쪽으로 하는 게 맞나? (매매는 KIS)

층을 셋으로 나누면 머리가 풀립니다.

| 층 | 지금 | 진실 |
|---|---|---|
| **신호** (RSI, 분봉 조건) | 키움 RAM 우선 | 실매가 본 봉 = 백테가 볼 봉이어야 함 |
| **체결가** | KIS 주문 | 슬리피지·틱청산은 **KIS 틱**이 맞음 |
| **freeze** | 먼저 DB에 들어간 칸을 안 고침 | REST가 나중에 volume을 키우지 못하게 |

**빠른 쪽 = 거래소 진짜 값** 은 매매 정합 규칙이 아닙니다.
키움이 1초 빨라도 **주문은 KIS 호가/체결**로 나갑니다. 신호만 키움, 체결은 KIS — 이미 그렇게 쓰시는 구조입니다.

freeze의 「먼저」는 “증권사 경주 우승자”가 아니라 **나중에 덮지 마**입니다.
오늘 DB 레이스는 KIS가 잘 이겨서, 라벨이 `kis`인 칸이 68%입니다. 실매 신호는 그래도 키움을 봅니다. 종가가 87% 같아서 **대부분 같이 가고**, **약 9%**는 DB(Optuna)가 KIS 종가·실매가 키움일 수 있습니다.

싫어하시는 OHLC 폴백(틱 청산을 봉 고저로 채점)과는 다른 이야기입니다.

---

### 3) REST를 다른 열로, source는 증권사만?

아이디어는 맞습니다. `rest`는 증권사가 아니라 **채널**(키움 REST 갭보정)입니다.

다만 **굳이 지금 바꿀 필요는 없습니다.**

| 안 | 할 일 | 이득 |
|---|---|---|
| **안 바꿈** | 0 | 라벨만 헷갈림. freeze·엔진은 이미 동작 |
| source=키움/KIS, 별도 열=ws\|rest | `kis_ws` 갭보정, `bt_candle_source`, 백필, 웹 UI | 표가 예뻐짐. **분당 1행이면 승자 레이스는 그대로** |
| 증권사별 행을 둘 다 저장 | UNIQUE·조회·롤업·Optuna 전부 | 그때서야 백테가 **키움 시리즈만** 고를 수 있음. 승인 후 큰 작업 |

UNIQUE가 지금처럼 `(종목, 분, 시각)` 한 칸이면, 열을 나눠도 **숫자 정합은 안 좋아집니다.**
정합을 올리려면 “라벨 정리”가 아니라 **키움/KIS를 따로 저장하거나, DB 승자를 실매 프로바이더(키움)로 고정**하는 쪽입니다. 그건 핵심 저장 설계라 **선승인**이 필요합니다.

---

한 줄: **빠른 쪽을 쓰지 마세요. 신호=키움, 체결=KIS, freeze=덮지 않기.** REST 열 분리는 정리용이고, 지금 안 바꿔도 됩니다.

<reproduction_steps>
1. 캔버스 `live-db-candle-parity-design`에서 겹침 7,920분 표(종가 87.1%, recv KIS 86%)를 확인한다.
2. 설정이 `LIVE_TICK_PROVIDER=kiwoom`, 주문은 KIS인 점과 표의 「DB는 KIS만 9.1%」가 같은 이야기인지 본다.
3. REST 열 분리는 아직 코드 미적용인지(라벨만 논의) 확인한다.
4. Press Proceed/Mark as fixed when done.
</reproduction_steps>


<timestamp>Thursday, Aug 13, 2026, 8:55 PM (UTC+9)</timestamp>
<user_query>
실매 프로바이더(키움)로 고정이랑 따로저장 두가지 방법이 있다는건 이해가 반만됐어 내가 이해하기로는 이기는쪽을 하나 저장하는게 아니고 두개를 다 저장한다랑 실매 프로바이더 실매랑 똑같이 도는거 그래 쓰다보니 이해가 되는군 실매프로바이더만 저장하게 되면 나중에 다른 증권사로 옵투나를 돌렸을때 문제가 되고 둘다 저장하는게 맞긴 한데 이전에 두개 다 저장하고 잇긴 했엇지 그래 둘 다 저장하는게 좋겠네 그래야 증권사를 이거했다 저거했다 할 수 잇고 실매쪽은 메인을 골라 쓰면 되는거니 근데 여기서 문제는 갭보정이 키움이 빨라서 키움을 쓰는건데 이건 실매랑 옵투나 백테에 어떻게 쓰이나? 만약 두개 다 저장했다 치면 갭보정은 키움쪽에만 있을거인데 이건 어떻게 해결을 해야되지 이렇게 하는김에 지금  컬럼 추가하는게 낫겠다 두번 작업하는거보다 그래야 비교가 쉽지 않을까 지금처럼 rest때문에 꼬이지 않으니 그리고 영구구독도 비교에 안맞네 ls는 또 디비 쌓는건 꺼놨으니 애매하네 일단 영구구독은 스위치를 만들자 디비에 넣는건 두고 구독을 할지 말지에 대한 스위치 대답만 우선
</user_query>


구현은 하지 않고, 갭보정이 키움/KIS 이중 저장에서 어떻게 쓰이는지와 영구구독 스위치부터 코드 기준으로 확인하겠습니다.


이해가 맞습니다. **이긴 쪽 하나만 남기지 말고, 증권사별로 시리즈를 따로 저장**하는 쪽이 맞습니다. 구현은 아직 안 합니다. 설계만 정리합니다.

---

### 두 방법 (쓰신 이해가 맞음)

| | 실매 프로바이더만 저장 | **둘 다 저장 (채택)** |
|---|---|---|
| DB | 키움 칸만 | 키움 칸 + KIS 칸 |
| 실매 | 키움 읽음 | 메인(키움)만 읽음 |
| Optuna를 KIS로 | **데이터 없음** | `CANDLE_SOURCE=kis`로 가능 |
| 증권사 갈아타기 | 그날부터 다시 쌓아야 함 | 이미 있는 시리즈 고르면 됨 |

예전에 UNIQUE에 `source`가 있어서 둘 다 들어가게 짜 둔 흔적이 코드 DDL에 있습니다. **실DB는 source가 UNIQUE에 없어서** 한 칸 경주가 된 겁니다.

---

### 갭보정은 “세 번째 선수”가 아니라 **키움 시리즈의 구멍 메우기**

지금: 키움 REST → `source=rest`라는 **별 팀**으로 들어와서, 한 칸을 키움 WS·KIS WS와 **싸웁니다**.

둘 다 저장하면 이렇게 가면 됩니다.

```
종목 × 1분 × 증권사(source) × 채널(channel)
```

| source | channel | 정체 |
|---|---|---|
| kiwoom | ws | 키움 틱으로 만든 봉 |
| kiwoom | rest | 키움 REST 갭보정 (ka10080) |
| kis | ws | KIS 틱으로 만든 봉 |
| kis | rest | KIS REST 폴백 (지금 기본 **OFF**) |

**읽는 규칙 (실매 = Optuna 동일)**

1. 메인이 키움이면 → 그 분의 **키움 ws**가 있으면 그걸 씀
2. 키움 ws가 **없을 때만** 키움 rest로 구멍 메움
3. KIS 봉을 **키움 rest로 메우지 않음** (증권사 시리즈를 섞지 않음)
4. freeze는 **같은 증권사 안에서만** — 키움 ws가 이미 있으면 키움 rest가 덮지 않음

초등학생 버전:
공책을 **키움 노트 / 한투 노트** 두 권으로 나눔.
선생님(갭보정)은 **키움 노트에만** 빈 칸을 채워 줌. 한투 노트에 가서 고치지 않음.

그래서 “갭보정이 키움이라서 키움을 쓴다”는 **웜업·구멍**용이고, KIS로 Optuna 돌릴 때는 **KIS 노트만** 보면 됩니다. KIS 쪽에 빈 칸이 많으면 그건 KIS 시리즈의 한계이지, 키움 rest를 섞어 때우면 안 됩니다. (KIS REST 폴백을 켜면 그때만 `kis+rest`로 그 노트에 구멍을 채움.)

컬럼을 **지금 같이** 넣는 게 맞습니다. 나중에 `rest`를 증권사와 또 쪼개면 두 번 마이그레이션입니다.

- `source` = 증권사 (`kiwoom` / `kis`)
- `channel` = `ws` / `rest` / `rollup`
- UNIQUE = `(code, timeframe, candle_time, source)`
  - 같은 증권사·같은 분이 ws와 rest 둘 다 있으면? freeze면 **ws 우선, rest는 구멍만**. 또는 UNIQUE에 channel까지 넣고 읽을 때 위 규칙으로 디듑. 비교하기엔 **channel까지 UNIQUE**가 더 쉽습니다.

이건 저장 설계 변경이라 **승인 후에** 손댑니다.

---

### 영구구독 스위치 — 대답만

비교가 안 맞던 이유: KR 영구구독은 **키움/KIS 슬롯에서 빼고 LS RAM**으로 가 있고, LS는 DB 적재를 꺼 두셔서 `ws_ticks`에 삼성·하이닉스가 안 쌓입니다. 갭보정 REST만 키움으로 보강됩니다.

테이블에는 이미 행 단위 `enabled`가 있습니다. `enabled=0`이면 구독 목록에서 빠지고 **행은 남습니다.**

원하시는 건 그 위에 **마스터 스위치**로 이해했습니다.

- DB 목록(종목 행)은 그대로 둠
- 스위치 OFF → KR/US 영구구독 **구독 자체를 안 함** (LS/키움/KIS 전부)
- 스위치 ON → `enabled=1`인 행만 지금처럼 구독

행마다 끄고 켜는 건 기존 `enabled`, 전부 잠시 쉬는 건 마스터 스위치. 둘 다 있으면 운영이 편합니다.

LS DB 적재는 켜지 않는 한, 스위치를 켜도 **키움 vs KIS 틱 비교 표에는 영구구독이 계속 안 맞습니다.** 비교하려면 영구구독을 다시 키움/KIS에 올리거나, LS 틱 저장을 켜야 합니다. 그건 슬롯·용량 이슈라 스위치와 별개입니다.

---

요약: **둘 다 저장 + channel 컬럼**이 맞고, 갭보정은 **키움 노트 빈칸만**. 영구구독은 **목록 유지 + 구독 마스터 스위치**. 코드는 아직 안 건드렸습니다. 이중 저장·컬럼부터 갈지, 스위치만 먼저 갈지 정해 주시면 그때 설계 확정 후 수정하겠습니다.

<reproduction_steps>
1. 위 이해가 맞는지 확인한다: 키움/KIS 시리즈 분리, 갭보정은 키움 구멍만, 영구구독은 행 유지+구독 스위치.
2. 다음으로 이중저장+channel 컬럼을 할지, 영구구독 스위치만 먼저 할지 하나만 고른다.
3. Press Proceed/Mark as fixed when done.
</reproduction_steps>


<timestamp>Thursday, Aug 13, 2026, 9:06 PM (UTC+9)</timestamp>
<user_query>
영구구독은  ls로 고정할거긴한데 이건 뭐 길게보는 종목을 할거라 빠르게 사고 팔 필요가 없기 때문에 근데 틱저장을 켜면 영구구독이 아닌 후보들이 다 구독에 들어와버리니까 이애들은 풀백으로 쓰려고 하는건데 디비저장은 굳이고 영구구독만 쌓게는 못하나? 이단 마스터 스위치는 만들거고 그리고 너가 파악을 잘못한게 있는데 kis 갭보정은 쓸수가 없어 페이지로 되어 잇어서 너무 오래걸린단말이지 그래서 갭보정은 무조건 키움으로 고정이야 이걸 염두하고 다시 설계 한다면?
</user_query>


LS 틱 저장 범위와 영구구독 구독 경로를 확인한 뒤, 키움 전용 갭보정·이중 저장까지 포함해 설계를 다시 잡겠습니다.


CreatePlan

Dual candle LS perm

키움/KIS 분봉을 증권사별로 따로 저장하고(갭보정은 키움 REST만·키움 시리즈 구멍만), 영구구독은 LS 고정+마스터 스위치+LS DB는 영구구독만 쌓게 한다. KIS REST 갭보정은 설계에서 제외한다.

# 이중 분봉 저장 + LS 영구구독만 DB + 키움 갭보정 고정

## 정정 (이전 파악 오류)

- **KIS REST 갭보정은 쓰지 않는다.** 페이지 조회라 너무 느림. `WS_GAP_FILL_KIS_FALLBACK` 는 기본 OFF로 두고, 설계·주석·UI에서 “켜도 실매 경로로 쓰지 않음 / 키움 ka10080만” 으로 못 박는다. KIS 시리즈는 **WS 틱 집계만** (구멍은 비워 둠).
- 영구구독 KR은 **LS WS 고정** (길게 보는 종목, 키움/KIS 슬롯 안 씀). 후보 spill 용 LS와 **구독은 같이** 탈 수 있지만 **DB 적재는 영구구독만**.

## 목표 그림

```mermaid
flowchart LR
  subgraph liveSig [실매_신호]
    KW_WS[키움_WS틱]
    KW_REST[키움_REST_갭보정]
    KW_WS --> KW_BAR[ws_candles_source_kiwoom]
    KW_REST -->|"구멍만 freeze"| KW_BAR
  end
  subgraph liveFill [실매_체결]
    KIS_WS[KIS_WS틱]
    KIS_WS --> KIS_BAR[ws_candles_source_kis]
  end
  subgraph permLS [영구구독_LS]
    LS_WS[LS_WS]
    LS_WS -->|"owner 영구만"| LS_TICK[ls_ws_ticks]
    LS_WS -->|"후보 spill"| RAM[RAM만_폴백]
  end
  Optuna -->|CANDLE_SOURCE_빈값_키움시리즈| KW_BAR
  Optuna -->|CANDLE_SOURCE_kis| KIS_BAR
```

## A. `ws_candles` — 둘 다 저장 + channel

실DB UNIQUE는 지금 `(code, timeframe, candle_time)` 한 칸이라 키움/KIS/rest가 경주한다. 코드 DDL은 이미 source 포함 흔적이 있음 ([database.py](database.py) `CREATE TABLE ws_candles`).

- 컬럼 `channel` 추가: `ws` | `rest` | `rollup` (`varchar`, 기본 `ws`).
- UNIQUE를 **`(code, timeframe, candle_time, source, channel)`** 로 변경 → 같은 분에 `kiwoom+ws` 와 `kiwoom+rest` 를 **비교용으로 둘 다** 남길 수 있음.
- `source` 의미 고정: **증권사만** `kiwoom` | `kis` (더 이상 `rest` 를 source로 쓰지 않음). 기존 `source=rest` / `kw_rest` 행은 마이그레이션 때 `source=kiwoom`, `channel=rest`.
- 갭보정 쓰기: [kis_trader/ws/kis_ws.py](kis_trader/ws/kis_ws.py) `fill_gap_from_rest` 가 넣는 source를 `kiwoom` + `channel=rest` 로. **KIS 시리즈에 키움 REST를 넣지 않음.**
- freeze: **같은 (source, channel) 또는 같은 source의 ws가 이미 있으면 rest는 그 칸을 안 덮음.** 구멍(그 source에 그 분이 없음)만 INSERT.
- 읽기 (실매 RAM 병합 + Optuna [bt_candle_source.py](kis_trader/backtest/bt_candle_source.py)):
  - 메인 `LIVE_TICK_PROVIDER=kiwoom` → 분마다 **kiwoom+ws 우선, 없으면 kiwoom+rest**.
  - `CANDLE_SOURCE=kis` → **kis+ws만** (rest 없음이 정상).
  - 빈값(기본) = 실매와 동일 (키움 시리즈).
- 영향 파일: `kis_ws.py` flush/merge/RAM key, `bt_candle_source.py` 디듑, `post_sell_candle_backfill.py`, `fill_kiwoom_candles.py`, 웹 `CANDLE_SOURCE` 힌트, [docs/정합성.md](docs/정합성.md) · [docs/like_mcp.md/db_erd.md](docs/like_mcp.md/db_erd.md).
- **과거 한 칸 데이터**: 마이그레이션은 라벨 재매핑 + UNIQUE 재생성. 이미 freeze로 덮인 숫자는 복구 안 됨 (문서 §6과 동일). 새 UNIQUE 이후 날짜부터 이중 시리즈가 진실.

## B. 갭보정 — 키움 고정

- 실매 갭보정 본체는 계속 키움 `get_kiwoom_candles_df` / ka10080 ([ws_manager.py](kis_trader/network/ws_manager.py) `_fill_gap...`).
- `WS_GAP_FILL_KIS_FALLBACK`: 코드 경로가 남아 있어도 **설계상 사용 금지**. UI 힌트에 “페이지 REST라 느림, 쓰지 말 것”. 구현 시 강제 false 또는 호출 제거는 승인 범위에서 택1 (권장: 호출 가드 + 로그 1줄).
- 영구구독도 지금처럼 `_gap_refill_codes`에 넣어 **키움 REST로 ws_candles(kiwoom+rest) 웜업**은 유지 (LS는 분봉 갭이 약함). 틱 진실은 LS 테이블.

## C. 영구구독 — LS 고정, 마스터 스위치, DB는 영구만

**마스터 스위치** (행은 `permanent_subscriptions`에 유지)

- env `PERMANENT_SUBSCRIBE_ENABLED` (bool, 기본 true), [database.py](database.py) 키 등록 + [live_config_schema.py](kis_trader/web/live_config_schema.py) + 영구구독 탭 문구.
- OFF면 KR LS `sync_owner_codes` / `subscribe(..., owner=permanent)` 및 US 해외 WS 영구 구독을 **안 함**. 행·`enabled` 컬럼은 그대로.
- 종목 단위는 기존 `enabled` 유지. 실제 구독 = `마스터 ON` ∧ `enabled=1`.

**LS DB = 영구구독만** (후보 spill은 RAM 폴백)

지금 [ls_ws.py](kis_trader/ws/ls_ws.py) `LS_WS_TICK_SAVE` 가 켜지면 **구독된 모든 코드**가 `_tick_recorder` → [main.py](kis_trader/main.py) `insert_ls_ws_tick` 으로 들어감. 후보(condition/spill)까지 쌓이는 이유.

- 저장 가드: 코드가 **영구구독 집합**(테이블 enabled + 마스터 ON일 때 구독 중인 perm)일 때만 `ls_ws_ticks` / (동일 정책이면) `ls_ws_candles` INSERT.
- owner 문자열은 `permanent` vs `_permanent` 가 갈라져 있음 ([main.py](kis_trader/main.py) vs [ws_manager.py](kis_trader/network/ws_manager.py) `_sync_permanent_to_ls`). **owner 문자열에 의존하지 말고 code∈permanent_set** 으로 필터.
- 후보 LS 구독은 유지 (폴백 RAM). `LS_WS_TICK_SAVE=true` 여도 후보는 디스크에 안 씀.
- `LS_WS_TICK_MIRROR_WS_TICKS` 는 기본 false 유지. 영구 틱은 `ls_ws_ticks` 전용 (키움/KIS `ws_ticks`와 섞지 않음 → 후보 비교 표에 삼성전자가 안 끼는 상태와 일치).

## D. 하지 않을 것

- 키움 REST로 KIS 분봉 메우기
- KIS REST 갭보정 실용화
- 영구구독을 다시 키움/KIS 41/100 슬롯에 올리기
- UNIQUE 없이 channel만 추가 (또 한 칸 경주)
- OHLC 폴백으로 틱 청산 맞추기

## 구현 순서 (승인 후)

1. 영구구독 마스터 스위치 + LS 적재 가드 (운영 바로 이득, 스키마 작음)
2. `ws_candles` channel + UNIQUE 마이그레이션 + 갭보정 라벨 `kiwoom/rest` + 읽기 디듑
3. 웹/Optuna 기본=키움 시리즈, kis는 WS만. 문서·UI 힌트
4. 봇 재시작 후 로그: 갭보정 insert만 / LS tick insert가 perm 코드만

## 실매 영향

- **실매 신호**: 키움 시리즈(ws→rest 구멍) — 의도적으로 지금과 같게.
- **체결**: KIS 그대로.
- **백테/Optuna 기본**: 같은 키움 시리즈. `CANDLE_SOURCE=kis` 는 구멍 많은 WS-only.
- **영구구독**: 구독 OFF 가능, 켜면 LS 틱만 DB. 후보 LS는 RAM.

PERMANENT_SUBSCRIBE_ENABLED + LS tick/candle INSERT를 영구구독 코드만 (후보 spill는 RAM)

ws_candles channel 컬럼 + UNIQUE(code,tf,time,source,channel) + rest행을 kiwoom+rest로 마이그

걁보정은 키움 REST만, kiwoom 시리즈 구멍만. KIS fallback 사용 금지

실매/Optuna 읽기: 메인 증권사 ws 우선, 없으면 그 증권사 rest. 기본=kiwoom

정합.md / db_erd / live_config 힌트 갱신. 재시작 후 로그로 perm-only 적재·걁 insert 확인


<timestamp>Thursday, Aug 13, 2026, 9:45 PM (UTC+9)</timestamp>
<user_query>
B. 갭보정 — 키움 고정

실매 갭보정 본체는 계속 키움 get_kiwoom_candles_df / ka10080 (ws_manager.py _fill_gap...).

WS_GAP_FILL_KIS_FALLBACK: 코드 경로가 남아 있어도 설계상 사용 금지. UI 힌트에 “페이지 REST라 느림, 쓰지 말 것”. 구현 시 강제 false 또는 호출 제거는 승인 범위에서 택1 (권장: 호출 가드 + 로그 1줄).

영구구독도 지금처럼 _gap_refill_codes에 넣어 키움 REST로 ws_candles(kiwoom+rest) 웜업은 유지 (LS는 분봉 갭이 약함). 틱 진실은 LS 테이블.
영구구독은 갭보정이 굳이 필요한가 버튼만 하나 만들면 될 거 같은데 이건 증권사 확정보 가져와도 뭐 어차피 늘림목이라 굳이 실시간으로 쌓을 필요가 있나 싶은데
그리고
읽기 (실매 RAM 병합 + Optuna bt_candle_source.py):

메인 LIVE_TICK_PROVIDER=kiwoom → 분마다 kiwoom+ws 우선, 없으면 kiwoom+rest.

CANDLE_SOURCE=kis → kis+ws만 (rest 없음이 정상).

빈값(기본) = 실매와 동일 (키움 시리즈).
이부분은 kis캔들은 rest가 없잖아 그럼 옵투나에서는 어떻게 도는거지 kis로 돌렸을때
</user_query>


영구구독 자동 갭보정은 빼고 버튼으로 두는 쪽으로 계획을 고치겠습니다. KIS Optuna는 REST가 없을 때 빈 분이 어떻게 처리되는지도 코드로 확인합니다.


StrReplace

  - id: gapfill-kiwoom-only
    content: 걁보정은 키움 REST만, kiwoom 시리즈 구멍만. KIS fallback 사용 금지
    status: pending

  - id: gapfill-kiwoom-only
    content: 후보/보유만 키움 REST 자동 갭보정. 영구구독은 자동 제외, 탭 버튼으로 확정봉 수동 적재. KIS REST 금지
    status: pending

  - id: read-path-parity
    content: "실매/Optuna 읽기: 메인 증권사 ws 우선, 없으면 그 증권사 rest. 기본=kiwoom"
    status: pending

  - id: read-path-parity
    content: "실매/Optuna 기본=키움 ws→rest구멍. CANDLE_SOURCE=kis는 kis+ws만(빈 분은 스킵, 키움 REST로 안 메움)"
    status: pending

  subgraph permLS [영구구독_LS]
    LS_WS[LS_WS]
    LS_WS -->|"owner 영구만"| LS_TICK[ls_ws_ticks]
    LS_WS -->|"후보 spill"| RAM[RAM만_폴백]
  end
  Optuna -->|CANDLE_SOURCE_빈값_키움시리즈| KW_BAR
  Optuna -->|CANDLE_SOURCE_kis| KIS_BAR

  subgraph permLS [영구구독_LS]
    LS_WS[LS_WS]
    LS_WS -->|"영구만 DB"| LS_TICK[ls_ws_ticks]
    LS_WS -->|"후보 spill"| RAM[RAM만_폴백]
    BTN[탭_수동적재_버튼]
    BTN -->|"키움_확정봉_on_demand"| KW_BAR
  end
  Optuna -->|기본_키움_ws후rest구멍| KW_BAR
  Optuna -->|CANDLE_SOURCE_kis_WS만_구멍스킵| KIS_BAR

- 읽기 (실매 RAM 병합 + Optuna [bt_candle_source.py](kis_trader/backtest/bt_candle_source.py)):
  - 메인 `LIVE_TICK_PROVIDER=kiwoom` → 분마다 **kiwoom+ws 우선, 없으면 kiwoom+rest**.
  - `CANDLE_SOURCE=kis` → **kis+ws만** (rest 없음이 정상).
  - 빈값(기본) = 실매와 동일 (키움 시리즈).

- 읽기 (실매 RAM 병합 + Optuna [bt_candle_source.py](kis_trader/backtest/bt_candle_source.py)):
  - 메인 `LIVE_TICK_PROVIDER=kiwoom` / `CANDLE_SOURCE` 빈값 → 분마다 **kiwoom+ws 우선, 없으면 kiwoom+rest**.
  - `CANDLE_SOURCE=kis` → **kis+ws만**. rest로 메우지 않음. 그 분에 행이 없으면 백테는 그 분을 **검사하지 않음**(웜업 봉수 미달이면 매수 스킵). 키움 REST를 KIS 시리즈에 섞지 않음.

## B. 갭보정 — 키움 고정

- 실매 갭보정 본체는 계속 키움 `get_kiwoom_candles_df` / ka10080 ([ws_manager.py](kis_trader/network/ws_manager.py) `_fill_gap...`).
- `WS_GAP_FILL_KIS_FALLBACK`: 코드 경로가 남아 있어도 **설계상 사용 금지**. UI 힌트에 “페이지 REST라 느림, 쓰지 말 것”. 구현 시 강제 false 또는 호출 제거는 승인 범위에서 택1 (권장: 호출 가드 + 로그 1줄).
- 영구구독도 지금처럼 `_gap_refill_codes`에 넣어 **키움 REST로 ws_candles(kiwoom+rest) 웜업**은 유지 (LS는 분봉 갭이 약함). 틱 진실은 LS 테이블.

## B. 갭보정 — 키움 고정, 영구구독은 자동 제외

자동 갭보정 대상 = **후보·보유**(키움/KIS WS로 매매하는 종목)만. 본체는 키움 `get_kiwoom_candles_df` / ka10080 ([ws_manager.py](kis_trader/network/ws_manager.py)).

- `WS_GAP_FILL_KIS_FALLBACK`: **사용 금지** (페이지 REST, 느림). UI 힌트 + 호출 가드(강제 스킵 + 로그 1줄).
- **영구구독은 `_gap_refill_codes`에서 뺀다.** 늘림목이라 장중 키움 REST 연타가 필요 없음. 틱은 LS `ls_ws_ticks`만.
- 영구구독 탭에 **「확정봉 가져오기」(버튼)** 하나: 선택/전체 enabled KR에 대해 키움 REST를 **그때 1회** `ws_candles` (`kiwoom`+`rest`)에 INSERT-only. 기존 해외 US 적재 API와 같은 “눌러야 함” 모델. 실매 루프·갭 워커와 분리.

## B2. Optuna를 KIS로 돌리면 (rest 없음)

KIS 시리즈에는 갭보정이 없다 = **그 증권사가 그날 WS로 남긴 분만** 시험하는 것.

- `fetch_ws_candles_for_code`는 DB에 있는 행만 시간순으로 줌. 빈 분을 만들어 채우지 않음.
- 그 분에 `kis+ws`가 없으면 → 그 분 신호 없음. RSI 등 lookback이 모자라면 매수 스킵(지금과 동일 “봉 부족”).
- 그래서 KIS Optuna는 키움보다 **거래 수가 적을 수 있음** (KIS 구독 한도·미구독 구간). 그게 버그가 아니라 “KIS WS만으로 실매했을 때” 재현.
- 키움 rest로 KIS 구멍을 메우면 증권사 시리즈가 다시 섞여서, 이중 저장을 하는 이유가 사라짐.

비교할 때: 기본(키움 ws+rest구멍) vs `CANDLE_SOURCE=kis`(WS only). 공정 비교가 아니라 **피드 차이 비교**로 읽는다.

- **백테/Optuna 기본**: 같은 키움 시리즈. `CANDLE_SOURCE=kis` 는 구멍 많은 WS-only.
- **영구구독**: 구독 OFF 가능, 켜면 LS 틱만 DB. 후보 LS는 RAM.

- **백테/Optuna 기본**: 같은 키움 시리즈(ws→rest 구멍).
- **`CANDLE_SOURCE=kis`**: KIS WS 행만. 없는 분은 스킵. 키움 REST로 안 채움.
- **영구구독**: 구독 마스터 OFF 가능, 켜면 LS 틱만 DB. 자동 갭보정 없음. 확정봉은 탭 버튼.

- 영구구독을 다시 키움/KIS 41/100 슬롯에 올리기

- 영구구독을 다시 키움/KIS 41/100 슬롯에 올리기
- 영구구독을 장중 자동 `_gap_refill_codes`에 넣기 (버튼만)


두 가지 다 계획에 반영했습니다. 구현은 아직 안 합니다.

### 영구구독 갭보정

굳이 장중 자동으로 돌릴 필요 없습니다. 늘림목이라 키움 REST로 1분봉을 실시간 메울 이유가 약합니다.

- **자동 `_gap_refill_codes`에서 영구구독 제외**
- 영구구독 탭에 **「확정봉 가져오기」 버튼**만 — 누를 때 키움 확정봉을 `kiwoom+rest`로 1회 INSERT
- 평소 틱은 **LS `ls_ws_ticks`만**

### Optuna를 KIS로 돌리면 (rest가 없을 때)

빈 분을 키움 REST로 **채우지 않습니다.** 그게 이중 저장의 의미입니다.

백테는 DB에 **있는 행만** 시간순으로 봅니다. `kis+ws`가 없는 분은

- 그 분 **신호 없음**
- RSI 등 봉이 모자라면 **매수 스킵** (지금 “봉 부족”과 같음)

그래서 KIS Optuna는 키움보다 **거래가 적을 수 있습니다.** KIS 구독 한도·안 쌓인 구간이 그대로 나옵니다. “KIS WS만으로 그날 실매했을 때”를 재현하는 것이고, 키움으로 구멍을 메우면 또 시리즈가 섞입니다.

| 돌리는 방법 | 보는 봉 | 구멍 |
|---|---|---|
| 기본 (실매와 같게) | 키움 ws, 없으면 키움 rest | 후보·보유 갭보정으로 메움 |
| `CANDLE_SOURCE=kis` | KIS ws만 | 메우지 않음, 그 분 스킵 |

비교는 **공정 동일 데이터가 아니라 피드 차이 비교**로 보면 됩니다.


<timestamp>Thursday, Aug 13, 2026, 10:01 PM (UTC+9)</timestamp>
<user_query>
영구구독 탭에 「확정봉 가져오기」 버튼만 — 누를 때 키움 확정봉을 kiwoom+rest로 1회 INSERT
평소 틱은 LS ls_ws_ticks만
이거 rest는 어느 테이블에 넣는거지?

그래서 KIS Optuna는 키움보다 거래가 적을 수 있습니다. KIS 구독 한도·안 쌓인 구간이 그대로 나옵니다. “KIS WS만으로 그날 실매했을 때”를 재현하는 것이고, 키움으로 구멍을 메우면 또 시리즈가 섞입니다.

아닌데 실매에서도 kis메인으로 쓰더라도 갭보정은 키움인데 왜 ?? 난 이해가 안가는데
</user_query>


REST는 별도 테이블이 아니라 `ws_candles` 행입니다. KIS 메인이어도 갭보정은 키움이라는 실매 규칙에 맞춰 계획의 Optuna 읽기 경로를 고치겠습니다.