탭 기억 + Optuna 러닝/완료 시 잡 목록 자동 갱신을 넣습니다.

StrReplace

// ────────────────────────────────────────────
// 탭 전환
// ────────────────────────────────────────────
document.querySelectorAll('[data-tab]').forEach(el => {
  el.addEventListener('click', e => {
    e.preventDefault();
    document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active'));
    el.classList.add('active');
    const tab = el.dataset.tab;
    $('tab-actual').style.display    = tab === 'actual'   ? '' : 'none';
    $('tab-dashboard').style.display = tab === 'dashboard'? '' : 'none';
    $('tab-portfolio').style.display = tab === 'portfolio'? '' : 'none';
    $('tab-backtest').style.display  = tab === 'backtest' ? '' : 'none';
    $('tab-tail').style.display      = tab === 'tail'     ? '' : 'none';
    $('tab-dbband').style.display   = tab === 'dbband'  ? '' : 'none';
    $('tab-breakout').style.display  = tab === 'breakout' ? '' : 'none';
    $('tab-range_break').style.display = tab === 'range_break' ? '' : 'none';
    $('tab-momentum').style.display  = tab === 'momentum' ? '' : 'none';
    $('tab-us_momentum').style.display = tab === 'us_momentum' ? '' : 'none';
    $('tab-optuna').style.display    = tab === 'optuna'   ? '' : 'none';
    $('tab-holding').style.display   = tab === 'holding'  ? '' : 'none';
    $('tab-updownbox').style.display = tab === 'updownbox'? '' : 'none';
    $('tab-updow').style.display     = tab === 'updow'    ? '' : 'none';
    $('tab-dart').style.display      = tab === 'dart'     ? '' : 'none';
    $('tab-liveconfig').style.display = tab === 'liveconfig' ? '' : 'none';
    if (tab === 'dashboard') loadDashboard();
    if (tab === 'liveconfig') lcOnTabShow();
    if (tab === 'portfolio') pfLoad(true);
    if (tab === 'holding') hdLoadStocks();
    if (tab === 'updow') permLoad();
    if (tab === 'us_momentum') {
      updateUsMomFxHint();
      usmomLoadStocks(false);
      usmomOptSyncDatesFromMain();
      if (_usmomOptJobId) usmomStockOptunaStartPolling();
    }
    if (tab === 'dart') dartOnTabShow();
    if (tab === 'updownbox') ubxOnTabShow();
    if (tab === 'dbband') dbLoadStocks();
    if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors();
    if (tab === 'optuna') optunaOnTabShow();
    // 보유 라이브 시세: 보유·실거래 탭에서만 SSE/폴링 (다른 탭 이탈 시 중지)
    if (tab === 'portfolio') {
      /* pfRenderTable 에서 start */
    } else if (tab === 'actual') {
      const at = $('act_tbody');
      if (at && at.querySelector('[data-live-code]')) liveQuoteStartFromDom(at);
      else liveQuoteStop();
    } else {
      liveQuoteStop();
    }
  });
});

// ────────────────────────────────────────────
// 탭 전환 (새로고침해도 마지막 탭 유지)
// ────────────────────────────────────────────
const BT_TAB_KEY = 'kis_bt_active_tab';
const BT_TAB_NAMES = [
  'actual', 'dashboard', 'portfolio', 'backtest', 'tail', 'dbband', 'breakout',
  'range_break', 'momentum', 'us_momentum', 'optuna', 'holding', 'updownbox',
  'updow', 'dart', 'liveconfig',
];

function btShowTab(tab, opts) {
  opts = opts || {};
  if (!BT_TAB_NAMES.includes(tab)) tab = 'actual';
  const el = document.querySelector('[data-tab="' + tab + '"]');
  if (!el) return;
  document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active'));
  el.classList.add('active');
  $('tab-actual').style.display    = tab === 'actual'   ? '' : 'none';
  $('tab-dashboard').style.display = tab === 'dashboard'? '' : 'none';
  $('tab-portfolio').style.display = tab === 'portfolio'? '' : 'none';
  $('tab-backtest').style.display  = tab === 'backtest' ? '' : 'none';
  $('tab-tail').style.display      = tab === 'tail'     ? '' : 'none';
  $('tab-dbband').style.display   = tab === 'dbband'  ? '' : 'none';
  $('tab-breakout').style.display  = tab === 'breakout' ? '' : 'none';
  $('tab-range_break').style.display = tab === 'range_break' ? '' : 'none';
  $('tab-momentum').style.display  = tab === 'momentum' ? '' : 'none';
  $('tab-us_momentum').style.display = tab === 'us_momentum' ? '' : 'none';
  $('tab-optuna').style.display    = tab === 'optuna'   ? '' : 'none';
  $('tab-holding').style.display   = tab === 'holding'  ? '' : 'none';
  $('tab-updownbox').style.display = tab === 'updownbox'? '' : 'none';
  $('tab-updow').style.display     = tab === 'updow'    ? '' : 'none';
  $('tab-dart').style.display      = tab === 'dart'     ? '' : 'none';
  $('tab-liveconfig').style.display = tab === 'liveconfig' ? '' : 'none';
  if (!opts.skipSave) {
    try { localStorage.setItem(BT_TAB_KEY, tab); } catch (e) { /* ignore */ }
    try {
      if (location.hash !== '#' + tab) history.replaceState(null, '', '#' + tab);
    } catch (e) { /* ignore */ }
  }
  if (tab === 'dashboard') loadDashboard();
  if (tab === 'liveconfig') lcOnTabShow();
  if (tab === 'portfolio') pfLoad(true);
  if (tab === 'holding') hdLoadStocks();
  if (tab === 'updow') permLoad();
  if (tab === 'us_momentum') {
    updateUsMomFxHint();
    usmomLoadStocks(false);
    usmomOptSyncDatesFromMain();
    if (_usmomOptJobId) usmomStockOptunaStartPolling();
  }
  if (tab === 'dart') dartOnTabShow();
  if (tab === 'updownbox') ubxOnTabShow();
  if (tab === 'dbband') dbLoadStocks();
  if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors();
  if (tab === 'optuna') optunaOnTabShow();
  // 보유 라이브 시세: 보유·실거래 탭에서만 SSE/폴링 (다른 탭 이탈 시 중지)
  if (tab === 'portfolio') {
    /* pfRenderTable 에서 start */
  } else if (tab === 'actual') {
    const at = $('act_tbody');
    if (at && at.querySelector('[data-live-code]')) liveQuoteStartFromDom(at);
    else liveQuoteStop();
  } else {
    liveQuoteStop();
  }
}

function btRestoreTab() {
  let tab = (location.hash || '').replace(/^#/, '');
  if (!BT_TAB_NAMES.includes(tab)) {
    try { tab = localStorage.getItem(BT_TAB_KEY) || ''; } catch (e) { tab = ''; }
  }
  if (!BT_TAB_NAMES.includes(tab)) tab = 'actual';
  btShowTab(tab);
}

document.querySelectorAll('[data-tab]').forEach(el => {
  el.addEventListener('click', e => {
    e.preventDefault();
    btShowTab(el.dataset.tab);
  });
});
window.addEventListener('hashchange', () => {
  const tab = (location.hash || '').replace(/^#/, '');
  if (BT_TAB_NAMES.includes(tab)) btShowTab(tab, { skipSave: true });
});


  try { localStorage.setItem(BT_TAB_KEY, tab); } catch (e) { /* ignore */ }
  if (!opts.fromHash) {
    try {
      if (location.hash !== '#' + tab) history.replaceState(null, '', '#' + tab);
    } catch (e) { /* ignore */ }
  }
  if (tab === 'dashboard') loadDashboard();
  if (tab === 'liveconfig') lcOnTabShow();
  if (tab === 'portfolio') pfLoad(true);
  if (tab === 'holding') hdLoadStocks();
  if (tab === 'updow') permLoad();
  if (tab === 'us_momentum') {
    updateUsMomFxHint();
    usmomLoadStocks(false);
    usmomOptSyncDatesFromMain();
    if (typeof _usmomOptJobId !== 'undefined' && _usmomOptJobId) usmomStockOptunaStartPolling();
  }
  if (tab === 'dart') dartOnTabShow();
  if (tab === 'updownbox') ubxOnTabShow();
  if (tab === 'dbband') dbLoadStocks();
  if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors();
  if (tab === 'optuna' && typeof optunaOnTabShow === 'function') optunaOnTabShow();
  // 보유 라이브 시세: 보유·실거래 탭에서만 SSE/폴링 (다른 탭 이탈 시 중지)
  if (tab === 'portfolio') {
    /* pfRenderTable 에서 start */
  } else if (tab === 'actual') {
    const at = $('act_tbody');
    if (at && at.querySelector('[data-live-code]') && typeof liveQuoteStartFromDom === 'function') {
      liveQuoteStartFromDom(at);
    } else if (typeof liveQuoteStop === 'function') {
      liveQuoteStop();
    }
  } else if (typeof liveQuoteStop === 'function') {
    liveQuoteStop();
  }
}

function btRestoreTab() {
  let tab = (location.hash || '').replace(/^#/, '');
  if (!BT_TAB_NAMES.includes(tab)) {
    try { tab = localStorage.getItem(BT_TAB_KEY) || ''; } catch (e) { tab = ''; }
  }
  if (!BT_TAB_NAMES.includes(tab)) tab = 'actual';
  btShowTab(tab);
}

document.querySelectorAll('[data-tab]').forEach(el => {
  el.addEventListener('click', e => {
    e.preventDefault();
    btShowTab(el.dataset.tab);
  });
});
window.addEventListener('hashchange', () => {
  const tab = (location.hash || '').replace(/^#/, '');
  if (BT_TAB_NAMES.includes(tab)) btShowTab(tab, { fromHash: true });
});

  if (!opts.skipSave) {
    try { localStorage.setItem(BT_TAB_KEY, tab); } catch (e) { /* ignore */ }
    try {
      if (location.hash !== '#' + tab) history.replaceState(null, '', '#' + tab);
    } catch (e) { /* ignore */ }
  }
  if (tab === 'dashboard') loadDashboard();
  if (tab === 'liveconfig') lcOnTabShow();
  if (tab === 'portfolio') pfLoad(true);
  if (tab === 'holding') hdLoadStocks();
  if (tab === 'updow') permLoad();
  if (tab === 'us_momentum') {
    updateUsMomFxHint();
    usmomLoadStocks(false);
    usmomOptSyncDatesFromMain();
    if (_usmomOptJobId) usmomStockOptunaStartPolling();
  }
  if (tab === 'dart') dartOnTabShow();
  if (tab === 'updownbox') ubxOnTabShow();
  if (tab === 'dbband') dbLoadStocks();
  if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors();
  if (tab === 'optuna') optunaOnTabShow();
  // 보유 라이브 시세: 보유·실거래 탭에서만 SSE/폴링 (다른 탭 이탈 시 중지)
  if (tab === 'portfolio') {
    /* pfRenderTable 에서 start */
  } else if (tab === 'actual') {
    const at = $('act_tbody');
    if (at && at.querySelector('[data-live-code]')) liveQuoteStartFromDom(at);
    else liveQuoteStop();
  } else {
    liveQuoteStop();
  }
}

function btRestoreTab() {
  let tab = (location.hash || '').replace(/^#/, '');
  if (!BT_TAB_NAMES.includes(tab)) {
    try { tab = localStorage.getItem(BT_TAB_KEY) || ''; } catch (e) { tab = ''; }
  }
  if (!BT_TAB_NAMES.includes(tab)) tab = 'actual';
  btShowTab(tab);
}

document.querySelectorAll('[data-tab]').forEach(el => {
  el.addEventListener('click', e => {
    e.preventDefault();
    btShowTab(el.dataset.tab);
  });
});
window.addEventListener('hashchange', () => {
  const tab = (location.hash || '').replace(/^#/, '');
  if (BT_TAB_NAMES.includes(tab)) btShowTab(tab, { skipSave: true });
});

function dashDrill(strategyId) {
  const radio = document.querySelector('input[name=act_strategy][value="' + strategyId + '"]');
  if (radio) radio.checked = true;
  const day = $('dash_date').value;
  if (day) {
    $('act_start').value = day;
    $('act_end').value = day;
  }
  btShowTab('actual');
  loadActual();
}

function dashDrill(strategyId) {
  const radio = document.querySelector('input[name=act_strategy][value="' + strategyId + '"]');
  if (radio) radio.checked = true;
  const day = $('dash_date').value;
  if (day) {
    $('act_start').value = day;
    $('act_end').value = day;
  }
  document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active'));
  const actTab = document.querySelector('[data-tab="actual"]');
  if (actTab) actTab.classList.add('active');
  $('tab-dashboard').style.display = 'none';
  $('tab-actual').style.display = '';
  $('tab-portfolio').style.display = 'none';
  $('tab-backtest').style.display = 'none';
  $('tab-tail').style.display = 'none';
  $('tab-breakout').style.display = 'none';
  $('tab-range_break').style.display = 'none';
  $('tab-momentum').style.display = 'none';
  $('tab-us_momentum').style.display = 'none';
  $('tab-holding').style.display = 'none';
  $('tab-updow').style.display = 'none';
  loadActual();
}

function btJobGoTab(strat) {
  const s = strat || _btJobStrategy || 'tail';
  const cfg = BTJOB_CFG[s] || BTJOB_CFG.tail;
  btShowTab(cfg.tab);
}

function btJobGoTab(strat) {
  const s = strat || _btJobStrategy || 'tail';
  const cfg = BTJOB_CFG[s] || BTJOB_CFG.tail;
  const el = document.querySelector('[data-tab="' + cfg.tab + '"]');
  if (el) el.click();
}

function optunaGoTab() {
  btShowTab('optuna');
}

function optunaGoTab() {
  const el = document.querySelector('[data-tab="optuna"]');
  if (el) el.click();
}


function usmomGoOptuna() {
  /* Optuna 탭 = 전역 US_MOMENTUM_* 만 (종목 cfg는 이 탭 종목 Optuna) */
  document.querySelectorAll('.opt-strat-cb').forEach(cb => {
    cb.checked = (cb.value === 'us_momentum');
  });
  document.querySelectorAll('.opt-tail-em').forEach(cb => { cb.checked = false; });
  btShowTab('optuna');
}

/** 해외 모멘텀 → 실거래 분석 탭 (국내 전략 탭과 동일: 전략탭에 실거래 UI 없음) */
function usmomActGoActualTab() {
  const radio = document.querySelector('input[name=act_strategy][value=US_MOMENTUM]');
  if (radio) radio.checked = true;
  const day = ($('act_start')?.value || '').trim() || kstTradingDayIso(_krHolidays);
  setDateVal('act_start', day);
  setDateVal('act_end', day);
  btShowTab('actual');
  loadActual();
}

function usmomGoOptuna() {
  /* Optuna 탭 = 전역 US_MOMENTUM_* 만 (종목 cfg는 이 탭 종목 Optuna) */
  document.querySelectorAll('.opt-strat-cb').forEach(cb => {
    cb.checked = (cb.value === 'us_momentum');
  });
  document.querySelectorAll('.opt-tail-em').forEach(cb => { cb.checked = false; });
  const el = document.querySelector('[data-tab="optuna"]');
  if (el) el.click();
}

/** 해외 모멘텀 → 실거래 분석 탭 (국내 전략 탭과 동일: 전략탭에 실거래 UI 없음) */
function usmomActGoActualTab() {
  const radio = document.querySelector('input[name=act_strategy][value=US_MOMENTUM]');
  if (radio) radio.checked = true;
  const day = ($('act_start')?.value || '').trim() || kstTradingDayIso(_krHolidays);
  setDateVal('act_start', day);
  setDateVal('act_end', day);
  const el = document.querySelector('[data-tab="actual"]');
  if (el) el.click();
  loadActual();
}

const OPTUNA_LS_KEY = 'kis_optuna_job_id';
let _optunaPollTimer = null;
let _optunaJobId = localStorage.getItem(OPTUNA_LS_KEY) || '';
let _optunaPollStatus = '';
let _optunaJobsRefreshing = false;

async function optunaRefreshJobsIfNeeded(force) {
  if (_optunaJobsRefreshing && !force) return;
  _optunaJobsRefreshing = true;
  try {
    await optunaRefreshJobs();
  } finally {
    _optunaJobsRefreshing = false;
  }
}

function optunaGoTab() {
  btShowTab('optuna');
}

const OPTUNA_LS_KEY = 'kis_optuna_job_id';
let _optunaPollTimer = null;
let _optunaJobId = localStorage.getItem(OPTUNA_LS_KEY) || '';

function optunaGoTab() {
  btShowTab('optuna');
}

async function optunaPollOnce() {
  if (!_optunaJobId) {
    // 외부 CLI / latest
    try {
      const r = await fetch('/api/optuna/active');
      const j = await r.json();
      if (j.running && j.running[0]) {
        _optunaJobId = j.running[0].job_id;
        localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId);
        const st = j.running[0].status || 'running';
        const prev = _optunaPollStatus;
        _optunaPollStatus = st;
        optunaRenderJob(j.running[0]);
        await optunaRefreshJobsIfNeeded(prev !== st);
        return;
      }
      if (j.latest) {
        if (j.latest.job_id) {
          _optunaJobId = j.latest.job_id;
          localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId);
        }
        const st = j.latest.status || '';
        const prev = _optunaPollStatus;
        _optunaPollStatus = st;
        optunaRenderJob(j.latest);
        if (j.latest.status === 'done' || j.latest.status === 'error') {
          optunaSetNav(j.latest);
        }
        if (prev !== st || st === 'running') await optunaRefreshJobsIfNeeded(prev !== st && (st === 'done' || st === 'error'));
      } else if (j.external) {
        optunaSetNav({
          label: 'CLI Optuna',
          status: 'running',
          progress: { pct: 5, trials_done: '?', trials_total: '?' },
          study_name: j.external.cmdline,
        });
      } else {
        optunaSetNav(null);
      }
    } catch (e) { /* ignore */ }
    return;
  }
  try {
    const jid = _optunaJobId;
    const r = await fetch('/api/optuna/status/' + encodeURIComponent(jid));
    const j = await r.json();
    if (jid !== _optunaJobId) return;
    if (!j.ok) {
      _optunaJobId = '';
      localStorage.removeItem(OPTUNA_LS_KEY);
      return;
    }
    const st = j.job.status || '';
    const prev = _optunaPollStatus;
    const finishedNow = (st === 'done' || st === 'error') && prev === 'running';
    const busy = st === 'running' || optunaPostBusy(j.job);
    _optunaPollStatus = st;
    optunaRenderJob(j.job);
    // 러닝 중·상태 변화·완료 직후 → 잡 목록도 자동 갱신 (수동 새로고침 불필요)
    if (busy || finishedNow || prev !== st) {
      await optunaRefreshJobsIfNeeded(finishedNow);
    }
  } catch (e) { /* ignore */ }
}

async function optunaPollOnce() {
  if (!_optunaJobId) {
    // 외부 CLI / latest
    try {
      const r = await fetch('/api/optuna/active');
      const j = await r.json();
      if (j.running && j.running[0]) {
        _optunaJobId = j.running[0].job_id;
        localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId);
        optunaRenderJob(j.running[0]);
        return;
      }
      if (j.latest) {
        if (j.latest.job_id) {
          _optunaJobId = j.latest.job_id;
          localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId);
        }
        optunaRenderJob(j.latest);
        if (j.latest.status === 'done' || j.latest.status === 'error') {
          optunaSetNav(j.latest);
        }
      } else if (j.external) {
        optunaSetNav({
          label: 'CLI Optuna',
          status: 'running',
          progress: { pct: 5, trials_done: '?', trials_total: '?' },
          study_name: j.external.cmdline,
        });
      } else {
        optunaSetNav(null);
      }
    } catch (e) { /* ignore */ }
    return;
  }
  try {
    const jid = _optunaJobId;
    const r = await fetch('/api/optuna/status/' + encodeURIComponent(jid));
    const j = await r.json();
    if (jid !== _optunaJobId) return;
    if (!j.ok) {
      _optunaJobId = '';
      localStorage.removeItem(OPTUNA_LS_KEY);
      return;
    }
    optunaRenderJob(j.job);
    if (j.job.status === 'done' || j.job.status === 'error') {
      // 완료 후에도 상단 바는 잠시 유지 (클릭해 결과 확인)
    }
  } catch (e) { /* ignore */ }
}


<script src="{{ url_for('static', filename='js/backtest.js') }}?v=20260821tab"></script>

<script src="{{ url_for('static', filename='js/backtest.js') }}"></script>


Shell

sudo systemctl restart kis_backtest_web.service && sleep 2 && systemctl is-active kis_backtest_web.service && curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/ && curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:5050/static/js/backtest.js?v=20260821tab'

Restart backtest web and health-check


CallMcpTool

Open backtest dashboard for UI verify


Lock browser for interactions


Click Optuna tab


Check tab persist state via CDP

Reload page to test tab restore


(() => { const a=document.querySelector('[data-tab].active'); const opt=document.getElementById('tab-optuna'); const errs=[]; return JSON.stringify({activeTab:a&&a.dataset.tab,hash:location.hash,ls:localStorage.getItem('kis_bt_active_tab'),optDisplay:opt&&opt.style.display,hasBtShow:typeof btShowTab,scriptSrc:(document.querySelector('script[src*=backtest]')||{}).src}); })()

Verify Optuna tab restored after reload

(() => { const logs=[]; const orig=console.error; /* check performance entries */ return JSON.stringify({btShow:typeof window.btShowTab,restore:typeof window.btRestoreTab,optunaPoll:typeof window.optunaPollOnce}); })()

Confirm new tab helpers exist


(() => { try { return JSON.stringify({ok:true, jobsTbody:!!document.getElementById('opt_jobs_tbody'), jobsRows:(document.getElementById('opt_jobs_tbody')||{}).children&&document.getElementById('opt_jobs_tbody').children.length}); } catch(e){ return String(e);} })()

Check Optuna jobs table rendered

Unlock browser after verification


맞아요. 그래서 두 가지 고쳤습니다.

### 1. 새로고침해도 탭 유지
- 마지막 탭을 `localStorage` + URL `#optuna` 등에 저장
- F5해도 **있던 탭**으로 복원 (실거래로 튀지 않음)

### 2. Optuna 자동 갱신
- **러닝 중**·**상태 바뀜**·**완료 직후** → 잡 목록이 폴링으로 같이 갱신
- 완료되면 Top5/후처리 요약도 status 폴링으로 바로 뜸 (수동 「새로고침」 불필요)

### 검증
- `kis_backtest_web` **active** · HTTP 200
- `http://192.168.0.149:5050/` → Optuna 클릭 → 새로고침 → `#optuna` 유지 · 잡 표 8행
- 콘솔 오류 없음 (스냅샷 기준)

캐시 때문에 안 보이면 한 번만 강력 새로고침 하세요 (`?v=20260821tab` 붙어 있음).


<timestamp>Friday, Aug 21, 2026, 10:16 AM (UTC+9)</timestamp>
<user_query>
그거는? 지금 옵투나 돌릴만한 제대로 된 디비 데이터는 몇일 몇시부터지? 그 쓰레기데이터 거르는거랑 1차2차3차 데이터 넣기 시작한 때
</user_query>


옵투나에 쓸 만한 DB 데이터 시작 시점과 쓰레기 필터·1·2·3차 적재 시점을 문서/코드에서 찾습니다.

Grep

1차|2차|3차|쓰레기|garbage|정합.*시작|데이터.*넣|적재.*시작|옵투나.*날짜|신뢰.*기간


8월|07월|시작|적재|멀티|1차|키움.*한투|spill|LIVE_TICK|제대로|쓰레기|2026-08-1

몇일|부터|쓰레기|1차.*2차|옵투나.*기간|깨끗한|데이터.*넣


옵투나.*부터|백테.*부터|신뢰.*일|MIN_START|최소.*시작|8/19|8월19|틱 이중|kis.*kiwoom.*저장

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
# DB: first day with multi-source ticks / candles (schema-safe)
from database import TradeDB
db = TradeDB()
# schema first
for t in ('ws_ticks', 'ws_candles', 'ls_ws_ticks', 'ws_orderbook'):
    try:
        cols = [r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()]
        print(t, cols[:20], '...' if len(cols)>20 else '')
    except Exception as e:
        print(t, 'ERR', e)
PY

SHOW COLUMNS for tick/candle tables


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

# Per-day source presence for recent weeks
q = """
SELECT DATE(tick_time) d, source, COUNT(*) n,
       MIN(tick_time) tmin, MAX(tick_time) tmax
FROM ws_ticks
WHERE tick_time >= '2026-08-01'
GROUP BY DATE(tick_time), source
ORDER BY d, source
"""
rows = db.conn.execute(q).fetchall()
print('=== ws_ticks by day/source since 8/1 ===')
for r in rows:
    print(dict(r) if hasattr(r,'keys') else r)

# First day with BOTH kis and kiwoom
q2 = """
SELECT DATE(tick_time) d,
  SUM(source='kis') kis_n,
  SUM(source='kiwoom') kw_n,
  SUM(source='ls') ls_n
FROM ws_ticks
WHERE tick_time >= '2026-07-01'
GROUP BY DATE(tick_time)
HAVING kis_n > 1000 AND kw_n > 1000
ORDER BY d
LIMIT 15
"""
print('\n=== days with both kis+kiwoom substantial ===')
for r in db.conn.execute(q2).fetchall():
    print(dict(r) if hasattr(r,'keys') else r)

# candles multi-source
q3 = """
SELECT DATE(STR_TO_DATE(candle_time,'%%Y%%m%%d%%H%%i')) d, source, channel, COUNT(*) n
FROM ws_candles
WHERE candle_time >= '202608010000'
  AND timeframe=1
GROUP BY d, source, channel
ORDER BY d, source, channel
"""
print('\n=== ws_candles 1m by day/source/channel ===')
for r in db.conn.execute(q3).fetchall():
    print(dict(r) if hasattr(r,'keys') else r)
PY

Query multi-source tick/candle start dates


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

# ls ticks earliest
print('=== ls_ws_ticks ===')
for r in db.conn.execute("""
SELECT DATE(ts) d, COUNT(*) n, MIN(ts) tmin, MAX(ts) tmax
FROM ls_ws_ticks
WHERE ts >= '2026-08-01'
GROUP BY DATE(ts) ORDER BY d
""").fetchall():
    print(dict(r))

print('\n=== ws_orderbook by day/source ===')
for r in db.conn.execute("""
SELECT DATE(snap_time) d, source, COUNT(*) n, MIN(snap_time) tmin
FROM ws_orderbook
WHERE snap_time >= '2026-08-01'
GROUP BY DATE(snap_time), source
ORDER BY d, source
""").fetchall():
    print(dict(r))

print('\n=== ls_ws_orderbook ===')
try:
    for r in db.conn.execute("""
SELECT DATE(snap_time) d, COUNT(*) n, MIN(snap_time) tmin
FROM ls_ws_orderbook
WHERE snap_time >= '2026-08-01'
GROUP BY DATE(snap_time) ORDER BY d
""").fetchall():
        print(dict(r))
except Exception as e:
    # schema
    cols = [x['Field'] for x in db.conn.execute('SHOW COLUMNS FROM ls_ws_orderbook').fetchall()]
    print('cols', cols)
    print('err', e)

# first minute both sources on 8/14 and 8/18
print('\n=== first hour both sources 8/14 ===')
for r in db.conn.execute("""
SELECT LEFT(tick_time,12) hm, source, COUNT(*) n
FROM ws_ticks WHERE tick_time LIKE '20260814%'
GROUP BY hm, source ORDER BY hm, source LIMIT 40
""").fetchall():
    print(dict(r))

print('\n=== first 30 min 8/18 ===')
for r in db.conn.execute("""
SELECT LEFT(tick_time,12) hm, source, COUNT(*) n
FROM ws_ticks WHERE tick_time BETWEEN '20260818090000' AND '20260818093000'
GROUP BY hm, source ORDER BY hm, source
""").fetchall():
    print(dict(r))

print('\n=== first 30 min 8/19 ===')
for r in db.conn.execute("""
SELECT LEFT(tick_time,12) hm, source, COUNT(*) n
FROM ws_ticks WHERE tick_time BETWEEN '20260819090000' AND '20260819093000'
GROUP BY hm, source ORDER BY hm, source
""").fetchall():
    print(dict(r))
PY

Query LS/OB and first hours of dual ticks

Grep

8/14|8월 14|쓰레기|GARBAGE|1차.*2차.*3차|제대로|옵투나.*시작|듀얼|이중 저장|WS_TICK_SAVE_KIS


Shell

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

print('=== 8/14 first ticks by source (min) ===')
for r in db.conn.execute("""
SELECT source, MIN(tick_time) tmin, MAX(tick_time) tmax, COUNT(*) n
FROM ws_ticks WHERE tick_time LIKE %s GROUP BY source
""", ('20260814%',)).fetchall():
    print(dict(r))

print('\n=== 8/18 first ===')
for r in db.conn.execute("""
SELECT source, MIN(tick_time) tmin, COUNT(*) n
FROM ws_ticks WHERE tick_time LIKE %s GROUP BY source
""", ('20260818%',)).fetchall():
    print(dict(r))

print('\n=== 8/19 first ===')
for r in db.conn.execute("""
SELECT source, MIN(tick_time) tmin, COUNT(*) n
FROM ws_ticks WHERE tick_time LIKE %s GROUP BY source
""", ('20260819%',)).fetchall():
    print(dict(r))

print('\n=== 8/20 first ===')
for r in db.conn.execute("""
SELECT source, MIN(tick_time) tmin, COUNT(*) n
FROM ws_ticks WHERE tick_time LIKE %s GROUP BY source
""", ('20260820%',)).fetchall():
    print(dict(r))

# candles: first day with kis ws + kiwoom ws both meaningful
print('\n=== candle days with kis ws + kiwoom ws ===')
for r in db.conn.execute("""
SELECT LEFT(candle_time,8) d,
  SUM(source='kis' AND channel='ws') kis_ws,
  SUM(source='kiwoom' AND channel='ws') kw_ws,
  SUM(source='kiwoom' AND channel='rest') kw_rest
FROM ws_candles
WHERE timeframe=1 AND candle_time >= '202608100000'
GROUP BY d
HAVING kis_ws > 100 AND kw_ws > 100
ORDER BY d
""").fetchall():
    print(dict(r))

# freeze env?
from kis_trader.utils.env import get_env_from_db
for k in ['WS_CANDLE_FREEZE_ON_CONFIRM','CANDLE_GARBAGE_FALLBACK','LIVE_FEED_FALLBACK_MAX_AGE_SEC',
          'WS_TICK_SAVE_KIS','WS_TICK_SAVE_KIWOOM','LS_WS_TICK_SAVE','LS_WS_ORDERBOOK_SAVE']:
    print(k, '=', get_env_from_db(k, '?'))
PY

First tick times and candle dual-source days

Grep

옵투나.*8/|제대로.*데이터|쓰레기.*거르|1차.*넣|8월 1[489].*장|장시작.*적재|듀얼 저장|둘 다.*저장


DB에 실제로 쌓인 시각 기준으로 정리하면 이렇게입니다.

### 옵투나용 “제대로 된” 구간 (추천)

| 등급 | 시작 | 이유 |
|------|------|------|
| **추천** | **2026-08-18 09:00** | 한투+키움 틱이 **장시작부터** 둘 다 있음 |
| 아슬아슬 | 8/14 **09:46~** | 듀얼 틱 **첫날**이지만 장초(~09:46 전) 비어 있음 |
| 빼기 | **8/17** | 틱 거의 없음 (공백일) |
| 주말 | 8/15~16 | 장 없음 |

지금 돌리려면 start는 **`2026-08-18`** 이 맞고, 어제 쓰던 `8/17~20`은 **17일이 구멍**이라 빼는 게 좋습니다.

---

### 1차·2차·3차 “넣기 시작한 때” (DB 실측)

| 단계 | 무엇 | DB에 보이기 시작 |
|------|------|------------------|
| **1·2차 틱** | `ws_ticks` `kis` + `kiwoom` | **8/14 09:46** (부분) → **풀세션은 8/18 09:00:18** |
| **1·2차 분봉** | `ws_candles` kis/kiwoom `ws` | **8/12**부터 (그전은 키움 REST 위주) |
| **호가(키움 0D)** | `ws_orderbook` | **8/14 ~09:45** |
| **3차 LS 틱** | `ls_ws_ticks` | **8/14 10:41** (지금은 `LS_WS_TICK_SAVE=false`라 이후 듬성) |
| **3차 LS 호가** | `ls_ws_orderbook` | **8/19 14:36**부터 본격 |

문서상 증권사별 저장 규칙도 **2026-08-14** (`docs/정합성.md` §0.2).

밀도만 보면 **8/19~20**이 더 두껍고(키움 틱·LS 호가), **8/20 08:30**부터는 장전 틱도 있음.

---

### 쓰레기 거르기 (`CANDLE_GARBAGE_FALLBACK`)

이건 **그날부터 DB에 심은 스위치가 아니라**, Optuna/백테가 **읽을 때** 쓰레기 WS 봉을 버리고 다음 소스(2차·REST)로 넘기는 규칙입니다.
지금도 **ON** (`true`). “쓰레기 필터가 켜진 날부터 데이터가 깨끗하다”가 아니라, **위 듀얼 틱이 있는 날부터** 필터가 제 역할을 합니다.

---

### 한 줄

- **차트+틱 Optuna:** **`2026-08-18`부터** (17일 제외)
- **호가 후처리까지 두껍게:** **`2026-08-19` 오후~** 또는 **`2026-08-20`부터**가 더 안전


<timestamp>Friday, Aug 21, 2026, 10:21 AM (UTC+9)</timestamp>
<user_query>
14 15 16 17 데이터는 지워줘 그럼 괜히 헷갈리니까 디비 낭비이기도하고
</user_query>


8/14~17 시세·호가·봉 등 시장 데이터 범위를 확인한 뒤 삭제합니다. (설정·실거래 주문은 건드리지 않습니다.)

Shell

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

days = ['20260814', '20260815', '20260816', '20260817']
# date strings for datetime cols
days_dt = ['2026-08-14', '2026-08-15', '2026-08-16', '2026-08-17']

checks = []

# ws_ticks
for d in days:
    n = db.conn.execute(
        "SELECT COUNT(*) c FROM ws_ticks WHERE tick_time LIKE %s", (d + '%',)
    ).fetchone()['c']
    checks.append(('ws_ticks', d, n))

# ws_candles
for d in days:
    n = db.conn.execute(
        "SELECT COUNT(*) c FROM ws_candles WHERE candle_time LIKE %s", (d + '%',)
    ).fetchone()['c']
    checks.append(('ws_candles', d, n))

# ws_orderbook
for d in days:
    n = db.conn.execute(
        "SELECT COUNT(*) c FROM ws_orderbook WHERE snap_time LIKE %s", (d + '%',)
    ).fetchone()['c']
    checks.append(('ws_orderbook', d, n))

# ls_ws_ticks (datetime ts)
for d in days_dt:
    n = db.conn.execute(
        "SELECT COUNT(*) c FROM ls_ws_ticks WHERE ts >= %s AND ts < DATE_ADD(%s, INTERVAL 1 DAY)",
        (d + ' 00:00:00', d),
    ).fetchone()['c']
    checks.append(('ls_ws_ticks', d, n))

# ls_ws_orderbook
cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM ls_ws_orderbook').fetchall()]
print('ls_ws_orderbook cols', cols)
ob_time = 'snap_time' if 'snap_time' in cols else cols[1]
for d in days:
    n = db.conn.execute(
        f"SELECT COUNT(*) c FROM ls_ws_orderbook WHERE {ob_time} LIKE %s", (d + '%',)
    ).fetchone()['c']
    checks.append(('ls_ws_orderbook', d, n))

# ls_ws_candles
cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM ls_ws_candles').fetchall()]
print('ls_ws_candles cols', cols)
ct = 'candle_time' if 'candle_time' in cols else None
if ct:
    for d in days:
        n = db.conn.execute(
            f"SELECT COUNT(*) c FROM ls_ws_candles WHERE {ct} LIKE %s", (d + '%',)
        ).fetchone()['c']
        checks.append(('ls_ws_candles', d, n))

# ws_ticks_us
try:
    for d in days:
        n = db.conn.execute(
            "SELECT COUNT(*) c FROM ws_ticks_us WHERE tick_time LIKE %s", (d + '%',)
        ).fetchone()['c']
        checks.append(('ws_ticks_us', d, n))
except Exception as e:
    print('ws_ticks_us', e)

# program?
for t in ('ws_program', 'ws_program_trading', 'kiwoom_program'):
    try:
        cols = [r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()]
        print(t, cols[:12])
    except Exception:
        pass

print('\n=== counts ===')
for row in checks:
    if row[2]:
        print(row)
print('total rows to delete (nonzero):', sum(r[2] for r in checks))
PY

Count Aug14-17 rows in market tables


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
days = ['20260814','20260815','20260816','20260817']
days_dt = ['2026-08-14','2026-08-15','2026-08-16','2026-08-17']

print('ws_program')
for d in days:
    n = db.conn.execute("SELECT COUNT(*) c FROM ws_program WHERE snap_time LIKE %s",(d+'%',)).fetchone()['c']
    if n: print(d,n)

print('ls_ws_candles')
for d in days_dt:
    n = db.conn.execute(
        "SELECT COUNT(*) c FROM ls_ws_candles WHERE datetime >= %s AND datetime < DATE_ADD(%s, INTERVAL 1 DAY)",
        (d+' 00:00:00', d)).fetchone()['c']
    if n: print(d,n)

# history tables
for t in ('target_candidates_history','ls_universe_history','ws_price_validation','ws_price_validation_ls'):
    cols = [r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()]
    print(t, cols[:15])

# sample history time col
for t, col_candidates in [
    ('target_candidates_history', ['scan_time','event_time','slot_key','updated_at']),
    ('ls_universe_history', ['scan_time','event_time','ts','updated_at']),
]:
    cols = [r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()]
    for c in col_candidates:
        if c in cols:
            # count like
            try:
                if c == 'slot_key':
                    for d in days:
                        n = db.conn.execute(f"SELECT COUNT(*) c FROM {t} WHERE {c} LIKE %s",(d+'%',)).fetchone()['c']
                        if n: print(t,c,d,n)
                else:
                    for d in days_dt:
                        n = db.conn.execute(
                            f"SELECT COUNT(*) c FROM {t} WHERE {c} >= %s AND {c} < DATE_ADD(%s, INTERVAL 1 DAY)",
                            (d+' 00:00:00', d)).fetchone()['c']
                        if n: print(t,c,d,n)
            except Exception as e:
                print(t,c,'err',e)
            break

print('ws_ticks_us all days')
for d in days:
    n = db.conn.execute("SELECT COUNT(*) c FROM ws_ticks_us WHERE tick_time LIKE %s",(d+'%',)).fetchone()['c']
    print(d,n)
PY

Count related history/program rows 8/14-17


cd /home/hoon/kis_bot && python3 -u <<'PY'
"""Delete market/universe data for 2026-08-14 .. 2026-08-17 only.
Does NOT touch orders/trades/config/optuna studies.
"""
from database import TradeDB

db = TradeDB()
days = ['20260814', '20260815', '20260816', '20260817']
days_dt = ['2026-08-14', '2026-08-15', '2026-08-16', '2026-08-17']
deleted = []

def run(label, sql, params=None):
    cur = db.conn.execute(sql, params or ())
    n = cur.rowcount if cur.rowcount is not None and cur.rowcount >= 0 else None
    # pymysql rowcount for DELETE
    deleted.append((label, n))
    print(f'OK {label}: rowcount={n}')

# string-key tables
for table, col in [
    ('ws_ticks', 'tick_time'),
    ('ws_ticks_us', 'tick_time'),
    ('ws_candles', 'candle_time'),
    ('ws_orderbook', 'snap_time'),
    ('ws_program', 'snap_time'),
    ('ls_ws_orderbook', 'snap_time'),
]:
    for d in days:
        run(f'{table} {d}', f'DELETE FROM {table} WHERE {col} LIKE %s', (d + '%',))

# ls_ws_ticks datetime
for d in days_dt:
    run(
        f'ls_ws_ticks {d}',
        'DELETE FROM ls_ws_ticks WHERE ts >= %s AND ts < DATE_ADD(%s, INTERVAL 1 DAY)',
        (d + ' 00:00:00', d),
    )

# ls_ws_candles
for d in days_dt:
    run(
        f'ls_ws_candles {d}',
        'DELETE FROM ls_ws_candles WHERE datetime >= %s AND datetime < DATE_ADD(%s, INTERVAL 1 DAY)',
        (d + ' 00:00:00', d),
    )

# universe history (Optuna 헷갈림 방지)
for d in days_dt:
    run(
        f'target_candidates_history {d}',
        'DELETE FROM target_candidates_history WHERE scan_time >= %s AND scan_time < DATE_ADD(%s, INTERVAL 1 DAY)',
        (d + ' 00:00:00', d),
    )
for d in days_dt:
    run(
        f'ls_universe_history {d}',
        'DELETE FROM ls_universe_history WHERE scan_time >= %s AND scan_time < DATE_ADD(%s, INTERVAL 1 DAY)',
        (d + ' 00:00:00', d),
    )

# price validation
for d in days_dt:
    run(
        f'ws_price_validation {d}',
        'DELETE FROM ws_price_validation WHERE ts >= %s AND ts < DATE_ADD(%s, INTERVAL 1 DAY)',
        (d + ' 00:00:00', d),
    )
    run(
        f'ws_price_validation_ls {d}',
        'DELETE FROM ws_price_validation_ls WHERE ts >= %s AND ts < DATE_ADD(%s, INTERVAL 1 DAY)',
        (d + ' 00:00:00', d),
    )

# verify leftovers
print('\n=== verify leftover ===')
for table, col in [
    ('ws_ticks', 'tick_time'),
    ('ws_ticks_us', 'tick_time'),
    ('ws_candles', 'candle_time'),
    ('ws_orderbook', 'snap_time'),
]:
    for d in days:
        n = db.conn.execute(
            f'SELECT COUNT(*) c FROM {table} WHERE {col} LIKE %s', (d + '%',)
        ).fetchone()['c']
        if n:
            print('LEFT', table, d, n)
print('done')
total = sum(n for _, n in deleted if isinstance(n, int) and n > 0)
print('sum rowcount', total)
PY

DELETE Aug14-17 market and history rows


**2026-08-14 ~ 17** 시장·유니버스 데이터 삭제했습니다. 약 **107만 행**.

| 테이블 | 주요 삭제 |
|--------|-----------|
| `ws_ticks` | 8/14 (17만) |
| `ws_ticks_us` | 8/17 (6.2만) |
| `ws_candles` | 8/14·15·17 |
| `ws_orderbook` / `ws_program` | 8/14 |
| `ls_ws_ticks` / `ls_ws_candles` | 8/14 |
| `target_candidates_history` | 8/14·15·17 |

검증: 해당일 `ws_ticks`·`ws_candles`·`ws_orderbook` **잔여 0**.

**안 지움:** 주문·체결·설정·Optuna study.

이제 옵투나는 **`2026-08-18`부터** 잡으면 됩니다.


<timestamp>Friday, Aug 21, 2026, 2:25 PM (UTC+9)</timestamp>
<user_query>
📈 KIS Quant Dashboard
모멘텀 완료 200/200
100% · best#103 WR 53.3% · PnL 19,877원 · PF 1.16 · MDD 37,572 · 30건
모멘텀 백테 완료 2026-08-12~2026-08-18
100%
업데이트: 2026. 8. 21. 오전 10:24:13
매매
모의 · active
시세:KIS(실) | 호가:키움(실) | DB:후보
📊 실거래 분석
📈 오늘 운영
⚙ 운영 설정
💼 보유·매도
📉 스캘핑 백테스트
🎣 꼬리잡기 백테스트
🚀 돌파매매 백테스트
🌊 모멘텀 백테스트
🇺🇸 해외 모멘텀
🔬 Optuna
📈 홀딩 전략
📦 박스엔진
📡 영구구독 관리
📢 DART 수주
🔬 Optuna TPE 탐색
서버 subprocess · apply-best 없음 · 해외모멘텀=전역만(종목 cfg는 🇺🇸탭)
전략 (여러 개면 순차 · 해외는 따로 체크)
모멘텀
해외모멘텀(전역)
꼬리 진입
align 다음봉시가
limit_atr 지정가
돌파 손절
fixed 고정%
atr ATR배수
스캘핑
시작
2026-08-18
종료
2026-08-20
trials
200
스터디 총 횟수
비움=이번
mode
tpe
이력소스
키움 (target_candidates)
캔들소스
기본
틱소스
기본
호가소스
기본

탐색 게이트 WR/PF=0 · 사후=results_gated · 안정=results_stable · DB 자동적용 안 함 · 2개 이상=순차(병렬 아님) · 이력소스=저장 후보 테이블 · 꼬리: align/limit_atr 중 켠 것만 · 돌파: fixed/atr 중 켠 것만(둘 다=스터디 2개 순차). 별도 「꼬리」「돌파」체크는 없음
진행
학습 trial
후처리 (호가·휩쏘 · 끝나면 「상세」)
남은 12회 (시도 188 / 목표 200 · COMPLETE 188)
상태 done
잡 import_optuna_momentum_tpe_20260821_104243
study momentum_tpe_20260818_20260820_20260821_102454
trial 200 / 200 (100%) · best#103 WR 53.3% · PnL 19,877원 · PF 1.16 · MDD 37,572 · 30건
로그 —
결과 JSON /home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260821_104243.json
상위(gated): trial #103 · 거래 30 · WR 53.3% · PF 1.16 · PnL 19,877원 · gated 21/21 · stable 3
 과적합 가능도 16% · 상대적으로 낮음  과적합 가능도 16% · 상대적으로 낮음  — 완료 후에만 · TIME_* 미변경 · 위 버튼=타점만 · 트레일/호가는 아래 표 · confirm 필수 · mode_combo=TopN 최빈값 조합(표에서 뺌)
스터디 과적합 요약 (휴리스틱 · 교차검증 아님 · 적용은 아래 각 행 과적합% 보세요)
과적합 16% · 적용가능 84% · 상대적으로낮음
휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장
요인	가점	설명
표본 장일	+16.0	거래일≈3일 — 다일 재검증 권장(≥5일)
거래 표본	+0.0	상위 후보 거래 30건 — 상대적 양호
승률·PF 이상치	+0.0	이상치 없음
게이트 커버	+0.0	사후합격 21건 · stable 3건
PnL 고원	+0.0	고원 비율 5% (1건)
임계값 분포 (참고 · 적용 판단용 아님)
과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다.
다단트레일 추천 (탐색 축 아님 · DB 적용 시에만 반영)
MOMENTUM · ENABLED=true · MODE=trailing
ARM 10,000원 · TIERS 10000:40,20000:30,40000:20
앵커 14,541원 (mode/median/best×frac) · 타점 적용과 별도 · 이후 운영탭에서 끄기/수정 가능
학습 Top5 results · Optuna 목적함수 1위 풀 · 진행바 best=여기 1위
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
1	#103	30	53.3%	1.16	19,877	1	-30,734	-24368.63	16% · 상대적으로 낮음
2	#186	27	55.6%	1.15	15,657	2	-21,161	-19066.50	16% · 상대적으로 낮음
3	#107	30	56.7%	1.13	15,152	2	-22,740	-20166.99	16% · 상대적으로 낮음
4	#131	30	53.3%	1.12	13,930	1	-26,401	-21428.80	16% · 상대적으로 낮음
5	#175	28	53.6%	1.11	11,272	2	-19,674	-20335.76	16% · 상대적으로 낮음
사후합격 Top5 results_gated · 「상세」=아래 후처리 표(8방 호가)
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
1	#103	30	53.3%	1.16	19,877	1	-30,734	-24368.63	16% · 상대적으로 낮음
2	#186	27	55.6%	1.15	15,657	2	-21,161	-19066.50	16% · 상대적으로 낮음
3	#107	30	56.7%	1.13	15,152	2	-22,740	-20166.99	16% · 상대적으로 낮음
4	#131	30	53.3%	1.12	13,930	1	-26,401	-21428.80	16% · 상대적으로 낮음
5	#175	28	53.6%	1.11	11,272	2	-19,674	-20335.76	16% · 상대적으로 낮음
안정 Top5 results_stable · 「상세」=아래 후처리 표(8방 호가) · 손실일/최악일 게이트
안정점수 = 일평균PnL − λ×일표준편차 (원). 높을수록 좋음 · 만점 없음. 게이트: 손실일≤1 · 최악일≥-30,000 · 활성일≥2 · λ=1
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
1	#177	31	51.6%	1.12	13,999	1	-25,762	-19464.56	16% · 상대적으로 낮음
2	#129	30	53.3%	1.08	10,167	1	-26,401	-21121.05	16% · 상대적으로 낮음
3	#131	30	53.3%	1.12	13,930	1	-26,401	-21428.80	16% · 상대적으로 낮음
후처리 비교표 (사후합격·안정 Top5 「상세」 · 열까지 적용=뒤쪽 축은 OFF · live 참고만)
  남은 12회 (시도 188 / 목표 200 · COMPLETE 188)
합의(gated+mode, 실매 제외)
호가 방: 100 진입만 · 6앵커 · PnL 30,399 · WR 66.7% · 휩쏘: 있음 · 트레일: ARM 10,000
합의=Top5(+mode) 8방 중 median PnL 최고 방 + median 파라미터. 실매 참고행 제외.
gated#1 · trial #103 · 과적합 가능도 16% · 상대적으로 낮음
과적합%=추정. 사후합격 후보 · 호가 경우의수=진입×익절×손절=8 · 휩쏘는 8방 밖
차트 타점: trial #103
30건 · 승률 53.3% · 19,877원
·
호가 8방 (진입×익절×손절)	trial	성적	적용
000 타점만(호가OFF)	0	추천있음
필터후 12건 · 승률 41.7% · -7,721원
필터전 승률 41.7%
100 진입만	150	스프레드≤3.5% · 잔량≥0.85 · EXIT OFF · STOP OFF
필터후 4건 · 승률 75.0% · 42,574원
필터전 12건 · 승률 41.7% · -7,721원
010 익절만	150	EXIT ON · OR≥0.35 · 보유4 · STOP OFF
필터후 12건 · 승률 41.7% · 1,166원
필터전 승률 41.7% · -7,721원
001 손절만	150	EXIT OFF · STOP ON · 손실0.0074 · OR≥0.77
필터후 12건 · 승률 41.7% · 16,014원
필터전 승률 41.7% · -7,721원
110 진입+익절	200	스프레드≤2.96% · 잔량≥0.77 · EXIT ON · OR≥0.66 · 보유3 · STOP OFF
필터후 4건 · 승률 75.0% · 42,574원
필터전 12건 · 승률 41.7% · -7,721원
101 진입+손절	200	스프레드≤5.68% · 잔량≥1.26 · EXIT OFF · STOP ON · 손실0.0106 · OR≥0.49
필터후 4건 · 승률 75.0% · 42,574원
필터전 12건 · 승률 41.7% · -7,721원
011 익절+손절	200	EXIT ON · OR≥0.34 · 보유4 · STOP ON · 손실0.0094 · OR≥0.68
필터후 12건 · 승률 41.7% · 26,771원
필터전 승률 41.7% · -7,721원
111 전부	250	스프레드≤5.36% · 잔량≥0.78 · EXIT ON · OR≥0.3 · 보유5 · STOP ON · 손실0.0096 · OR≥0.5
필터후 4건 · 승률 75.0% · 42,574원
필터전 12건 · 승률 41.7% · -7,721원
8=2³. 휩쏘는 아래 별도.
8방 밖	성적	적용
휩쏘	sub=26 · lb=78 · dip=0.0056
필터후 25건 · 승률 48.0% · -9,360원
필터전 승률 48.0%
live · 실매 참고 (적용 없음)
과적합%=추정. 실매 trade_history 참고 — Optuna 칸과 섞지 않음 · 과적합% 제외 · 호가 경우의수=진입×익절×손절=8 · 휩쏘는 8방 밖
차트 타점: trial —
20건 · 승률 30.0% · -51,935원
· 참고
호가 8방 (진입×익절×손절)	trial	성적	적용
000 타점만(호가OFF)	0	추천있음
필터후 20건 · 승률 30.0% · -51,935원
필터전 승률 30.0%
참고
100 진입만	150	스프레드≤0.42% · 잔량≥0.52 · EXIT OFF · STOP OFF
필터후 8건 · 승률 62.5% · 17,751원
필터전 20건 · 승률 30.0% · -51,935원
참고
010 익절만	150	EXIT ON · OR≥0.42 · 보유2 · STOP OFF
필터후 20건 · 승률 30.0% · -49,164원
필터전 승률 30.0% · -51,935원
참고
001 손절만	150	EXIT OFF · STOP ON · 손실0.0054 · OR≥0.55
필터후 20건 · 승률 30.0% · -51,648원
필터전 승률 30.0% · -51,935원
참고
110 진입+익절	200	스프레드≤5.34% · 잔량≥0.44 · EXIT ON · OR≥0.58 · 보유3 · STOP OFF
필터후 11건 · 승률 45.5% · -21,329원
필터전 20건 · 승률 30.0% · -51,935원
참고
101 진입+손절	200	스프레드≤0.48% · 잔량≥1.09 · EXIT OFF · STOP ON · 손실0.013 · OR≥0.66
필터후 7건 · 승률 57.1% · 13,615원
필터전 20건 · 승률 30.0% · -51,935원
참고
011 익절+손절	200	EXIT ON · OR≥0.39 · 보유2 · STOP ON · 손실0.0038 · OR≥0.29
필터후 20건 · 승률 30.0% · -49,164원
필터전 승률 30.0% · -51,935원
참고
111 전부	250	스프레드≤0.4% · 잔량≥0.43 · EXIT ON · OR≥0.35 · 보유2 · STOP ON · 손실0.0068 · OR≥0.45
필터후 8건 · 승률 62.5% · 20,523원
필터전 20건 · 승률 30.0% · -51,935원
참고
8=2³. 휩쏘는 아래 별도.
8방 밖	성적	적용
휩쏘	sub=30 · lb=54 · dip=0.005
필터후 35건 · 승률 25.7% · -44,142원
필터전 승률 25.7%
참고
선택 후보 상세 Top5「보기」선택
아직 선택 없음
—
브리핑 미리보기
# Optuna 브리핑 — 모멘텀 (tpe)
기간: 2026-08-18 ~ 2026-08-20 (거래일≈3일) · trials 200/200
탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1
- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다.
학습용 통과 188건 · 사후합격(results_gated) 21건

## 과적합·적용 가능도 (휴리스틱)
- **과적합 위험 16.0%** · **적용 가능도 84.0%** · 판정: **상대적으로낮음** — 휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장
  - 표본 장일: +16.0p — 거래일≈3일 — 다일 재검증 권장(≥5일)
  - 거래 표본: +0.0p — 상위 후보 거래 30건 — 상대적 양호
  - 승률·PF 이상치: +0.0p — 이상치 없음
  - 게이트 커버: +0.0p — 사후합격 21건 · stable 3건
  - PnL 고원: +0.0p — 고원 비율 5% (1건)
- 임계값 분포 표본: results_gated 상위 21건
| 파라미터 | median | p25~p75 | mode(비율) |
|---|---:|---|---|
| `use_ema_filter` | 0.0 | 0.0 ~ 0.0 | False (95%) |
| `ema_fast_period` | 9.0 | 8.0 ~ 10.0 | 10 (29%) |
| `ema_slow_period` | 33.0 | 30.0 ~ 39.0 | 30 (24%) |
| `mom_vol_mult` | 9.1 | 8.8 ~ 9.2 | 9.2 (33%) |
| `mom_vol_win` | 5.0 | 5.0 ~ 5.0 | 5 (100%) |
| `e_min_chg_pct` | 1.9 | 1.8 ~ 2.1 | 1.7 (19%) |
| `tp_pct` | 10.3 | 9.7 ~ 11.1 | 11.4 (19%) |
| `tp_max_pct` | 15.5 | 14.0 ~ 16.0 | 16.0 (24%) |
| `sl_pct` | 6.9 | 6.4 ~ 7.2 | 7.3 (19%) |
| `shoulder_min_high` | 4.5 | 4.4 ~ 4.6 | 4.6 (29%) |
| `shoulder_cut_pct` | 0.2 | 0.2 ~ 0.25 | 0.2 (52%) |
| `trail_pct` | 3.2 | 2.9 ~ 3.3 | 3.3 (24%) |
- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._

## 이전 장에서는
- 실매 동기간 청산 53건 · 승률 32% · 합산 PnL 104,435원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이).
- 사후합격 1위: trial #103 · 거래 30건 · 승률 53.3% · PF 1.16 · PnL 19,877원

## 앞으로 장에서는
- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토.
- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다.
- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. 승률 자랑보다 **손절이 살아 있는지**를 보세요.
- 사후합격이 있어도 ‘내일도 같은 장’이 아닙니다. 페이퍼/소액·모의로 1~2일 관찰 후 확대하세요.

_생성: 2026-08-21 10:51:32 (rule)_

## AI 코멘트

_(미생성: api_call_failed. 구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_
최근 잡 (8개)
정렬

시작↓
job_id	전략	기간	상태	trial	시작	종료
import_optuna_scalp_tpe_20260821_122053	스캘핑	2026-08-18~2026-08-20	done ·남은 20회 (시도 180 / 목표 200 · COMPLETE 180)	—/200 ·목표 180/200	2026-08-21T12:21:45	2026-08-21T12:21:45
import_optuna_breakout_tpe_20260821_120015	돌파(atr)	2026-08-18~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 200)	—/200 ·목표 200/200	2026-08-21T12:00:45	2026-08-21T12:00:45
import_optuna_breakout_tpe_20260821_114315	돌파(fixed)	2026-08-18~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 200)	—/200 ·목표 200/200	2026-08-21T11:43:45	2026-08-21T11:43:45
import_optuna_tail_tpe_20260821_113102	꼬리(limit_atr)	2026-08-18~2026-08-20	done ·남은 20회 (시도 180 / 목표 200 · COMPLETE 180)	—/200 ·목표 180/200	2026-08-21T11:31:44	2026-08-21T11:31:44
import_optuna_tail_tpe_20260821_111355	꼬리(align)	2026-08-18~2026-08-20	done ·남은 17회 (시도 183 / 목표 200 · COMPLETE 183)	—/200 ·목표 183/200	2026-08-21T11:14:45	2026-08-21T11:14:45
import_optuna_momentum_tpe_20260821_104243	모멘텀	2026-08-18~2026-08-20	done ·남은 12회 (시도 188 / 목표 200 · COMPLETE 188)	—/200 ·목표 188/200	2026-08-21T10:42:44	2026-08-21T10:42:44
opt_20260821_102454_seq	순차(모멘텀+꼬리(align+limit_atr)+돌파(fixed+atr)+스캘핑)	2026-08-18~2026-08-20	done ·남은 20회 (시도 180 / 목표 200 · COMPLETE 180)	200/200 ·목표 180/200	2026-08-21T10:24:54	2026-08-21T12:21:59
import_optuna_momentum_tpe_20260820_173859	모멘텀	2026-08-20~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 200)	200/200 ·목표 200/200	2026-08-21T09:58:04	2026-08-21T09:58:04

이어돌리기가 왜 나오냐 그리고 이게 기본은 마이너스고 수익은 후처리를 해야 나오네 그리고 매매건수가 매우 줄어들어 확실한거만 들어간다는거냐9? 근데 몇일전껀 이거보다 훨씬 결과가 좋았는데 지금 로직이 이상하게 바뀌어서 그런건 아니야? 지금 옵투나 다 기본으로 돌렸는데 이거 1차 2차 3차 쓰레기 이런거 비율도 나오가 로그를 남겨야 파악이 될 거 같은데 니도 코드만 보고는 파악이 안되는가본데
</user_query>


이어돌리기(남은 12회)와 후처리·건수 급감·예전 대비 악화 원인을 코드·스터디·DB로 확인합니다.

cd /home/hoon/kis_bot && python3 - <<'PY'
import optuna
from kis_trader.backtest.optuna_common import resolve_optuna_storage_url
from kis_trader.backtest.optuna_study_store import count_study_states, leftover_note, should_run_postprocess, load_row

name = 'momentum_tpe_20260818_20260820_20260821_102454'
st = optuna.load_study(study_name=name, storage=resolve_optuna_storage_url(None))
n_c, n_r, n_f = count_study_states(st)
from collections import Counter
states = Counter(str(t.state) for t in st.trials)
print('study', name)
print('states', dict(states))
print('count_study_states complete,running,finished', n_c, n_r, n_f)
print('n_trials attr', len(st.trials))
print('user_attrs', dict(st.user_attrs or {}))
row = load_row(name) or {}
print('db row study_trials', row.get('study_trials'), 'n_complete', row.get('n_complete'), 'leftover_note', row.get('leftover_note'), 'pp_status', row.get('pp_status'))
print('should_run', should_run_postprocess(n_f, int(row.get('study_trials') or 200)))
print('leftover_note calc', leftover_note(int(row.get('study_trials') or 200), n_f, n_complete=n_c))
PY

Inspect study states vs leftover note


cd /home/hoon/kis_bot && python3 - <<'PY'
import json
p='/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260821_104243.json'
with open(p) as f: d=json.load(f)
keys=[k for k in d if 'optuna' in k.lower() or 'complete' in k.lower() or 'finish' in k.lower() or 'prun' in k.lower()]
print({k:d.get(k) for k in sorted(keys)})
# postprocess sample - trades before/after
pp=(d.get('postprocess_topn') or {})
anchors=pp.get('postprocess_by_anchor') or []
print('anchors', len(anchors))
if anchors:
    a=anchors[0]
    print('anchor keys', list(a.keys())[:30])
    print('source', a.get('source'), 'trial', a.get('optuna_trial_number'))
    # find 000 vs 100
    ways=a.get('ob_8way') or a.get('ways') or a.get('cases') or []
    print('ways type', type(ways), (list(ways)[:3] if isinstance(ways,list) else list((ways or {}).keys())[:5]))
PY

Inspect JSON optuna counts and postprocess


cd /home/hoon/kis_bot && python3 - <<'PY'
# Compare candle/tick mix 8/18-20 vs old good period if any remains
from database import TradeDB
db=TradeDB()
for d in ['20260818','20260819','20260820','20260812','20260813']:
    c=db.conn.execute("""
      SELECT source, channel, COUNT(*) n FROM ws_candles
      WHERE timeframe=1 AND candle_time LIKE %s GROUP BY source, channel
    """,(d+'%',)).fetchall()
    t=db.conn.execute("""
      SELECT source, COUNT(*) n FROM ws_ticks WHERE tick_time LIKE %s GROUP BY source
    """,(d+'%',)).fetchall()
    print(d,'candles', [dict(x) for x in c], 'ticks', [dict(x) for x in t])
PY

Compare candle/tick mix by day


원인: import가 COMPLETE(188)만 써서 ‘남은 12회’가 뜹니다. 고치고, 봉 로드 시 1·2·3차/쓰레기 비율 로그를 넣겠습니다.


StrReplace

def save_payload(
    *,
    study_name: str,
    out_data: Dict[str, Any],
    n_complete: int = 0,
    briefing_text: Optional[str] = None,
    pp_status: str = "idle",
    pp_error: Optional[str] = None,
    job_id: str = "",
) -> None:
    name = str(study_name or "").strip()
    if not name or not isinstance(out_data, dict):
        return
    payload = json.dumps(out_data, ensure_ascii=False)
    brief = briefing_text
    if brief is None:
        bp = str(out_data.get("briefing_md_path") or "")
        if bp and os.path.isfile(bp):
            try:
                with open(bp, encoding="utf-8") as f:
                    brief = f.read()
            except Exception:
                brief = None
        md = str(out_data.get("briefing_md") or "")
        if brief is None and md and str(md).endswith(".md") and os.path.isfile(md):
            try:
                with open(md, encoding="utf-8") as f:
                    brief = f.read()
            except Exception:
                brief = None
    strat = str(out_data.get("strategy") or "")[:32]
    mode = str(out_data.get("mode") or "")[:32]
    start = (str(out_data.get("start") or "")[:10] or None)
    end = (str(out_data.get("end") or "")[:10] or None)
    jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None
    sym = str(out_data.get("symbol") or "")[:16] or None
    st_goal = parse_study_trials_value(out_data.get("optuna_study_trials"))
    # 게이트용 = 끝난 시도. COMPLETE만 쓰지 말 것(pruned 있으면 이어돌리기 오판).
    n_done = int(
        n_complete
        or out_data.get("optuna_n_finished")
        or out_data.get("optuna_trials_completed")
        or out_data.get("optuna_n_complete")
        or 0
    )
    n_ok = int(out_data.get("optuna_n_complete") or 0)
    st = str(pp_status or "idle")[:16]
    needed = 0 if st == "done" else (1 if leftover_trials(st_goal, n_done) > 0 else 0)
    note = leftover_note(st_goal, n_done, n_complete=n_ok if n_ok else None)
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            INSERT INTO optuna_study_result (
              study_name, strategy, mode, start_date, end_date, job_id, symbol,
              study_trials, n_complete, leftover_note, payload_json, briefing_md,
              pp_status, pp_error, pp_for_n, pp_needed
            ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            ON DUPLICATE KEY UPDATE
              strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)),
              mode=IF(VALUES(mode)='', mode, VALUES(mode)),
              start_date=COALESCE(VALUES(start_date), start_date),
              end_date=COALESCE(VALUES(end_date), end_date),
              job_id=COALESCE(VALUES(job_id), job_id),
              symbol=COALESCE(VALUES(symbol), symbol),
              study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials),
              n_complete=IF(VALUES(n_complete)>0, VALUES(n_complete), n_complete),
              leftover_note=COALESCE(VALUES(leftover_note), leftover_note),
              payload_json=VALUES(payload_json),
              briefing_md=COALESCE(VALUES(briefing_md), briefing_md),
              pp_status=VALUES(pp_status),
              pp_error=VALUES(pp_error),
              pp_for_n=IF(VALUES(pp_status)='done', VALUES(pp_for_n), pp_for_n),
              pp_needed=IF(VALUES(pp_status)='done', 0, VALUES(pp_needed)),
              pp_lock_until=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_until),
              pp_lock_owner=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_owner)
            """,
            (
                name, strat, mode, start, end, jid, sym,
                int(st_goal or 0), n_done, (note[:200] if note else None), payload, brief,
                st, (str(pp_error)[:500] if pp_error else None),
                n_done if st == "done" else 0, needed,
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result payload 저장 실패: %s", exc)

def save_payload(
    *,
    study_name: str,
    out_data: Dict[str, Any],
    n_complete: int = 0,
    briefing_text: Optional[str] = None,
    pp_status: str = "idle",
    pp_error: Optional[str] = None,
    job_id: str = "",
) -> None:
    name = str(study_name or "").strip()
    if not name or not isinstance(out_data, dict):
        return
    payload = json.dumps(out_data, ensure_ascii=False)
    brief = briefing_text
    if brief is None:
        bp = str(out_data.get("briefing_md_path") or "")
        if bp and os.path.isfile(bp):
            try:
                with open(bp, encoding="utf-8") as f:
                    brief = f.read()
            except Exception:
                brief = None
        md = str(out_data.get("briefing_md") or "")
        if brief is None and md and str(md).endswith(".md") and os.path.isfile(md):
            try:
                with open(md, encoding="utf-8") as f:
                    brief = f.read()
            except Exception:
                brief = None
    strat = str(out_data.get("strategy") or "")[:32]
    mode = str(out_data.get("mode") or "")[:32]
    start = (str(out_data.get("start") or "")[:10] or None)
    end = (str(out_data.get("end") or "")[:10] or None)
    jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None
    sym = str(out_data.get("symbol") or "")[:16] or None
    st_goal = parse_study_trials_value(out_data.get("optuna_study_trials"))
    n_c = int(n_complete or out_data.get("optuna_n_complete") or out_data.get("optuna_trials_completed") or 0)
    st = str(pp_status or "idle")[:16]
    needed = 0 if st == "done" else 1
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            INSERT INTO optuna_study_result (
              study_name, strategy, mode, start_date, end_date, job_id, symbol,
              study_trials, n_complete, payload_json, briefing_md,
              pp_status, pp_error, pp_for_n, pp_needed
            ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            ON DUPLICATE KEY UPDATE
              strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)),
              mode=IF(VALUES(mode)='', mode, VALUES(mode)),
              start_date=COALESCE(VALUES(start_date), start_date),
              end_date=COALESCE(VALUES(end_date), end_date),
              job_id=COALESCE(VALUES(job_id), job_id),
              symbol=COALESCE(VALUES(symbol), symbol),
              study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials),
              n_complete=IF(VALUES(n_complete)>0, VALUES(n_complete), n_complete),
              payload_json=VALUES(payload_json),
              briefing_md=COALESCE(VALUES(briefing_md), briefing_md),
              pp_status=VALUES(pp_status),
              pp_error=VALUES(pp_error),
              pp_for_n=IF(VALUES(pp_status)='done', VALUES(pp_for_n), pp_for_n),
              pp_needed=IF(VALUES(pp_status)='done', 0, pp_needed),
              pp_lock_until=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_until),
              pp_lock_owner=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_owner)
            """,
            (
                name, strat, mode, start, end, jid, sym,
                int(st_goal or 0), n_c, payload, brief,
                st, (str(pp_error)[:500] if pp_error else None),
                n_c if st == "done" else 0, needed,
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result payload 저장 실패: %s", exc)

    n_done = int(
        data.get("optuna_n_finished")
        or data.get("optuna_trials_completed")
        or 0
    )
    n_ok = int(data.get("optuna_n_complete") or 0)
    if n_done <= 0:
        n_done = n_ok or payload_row_count(data) or 0
    st_goal = parse_study_trials_value(data.get("optuna_study_trials"))
    upsert_counts(
        study_name=name,
        strategy=str(data.get("strategy") or ""),
        mode=str(data.get("mode") or ""),
        start=str(data.get("start") or ""),
        end=str(data.get("end") or ""),
        job_id=job_id,
        symbol=str(data.get("symbol") or ""),
        study_trials=st_goal,
        n_complete=n_done,
        n_running=0,
        leftover_n_complete=n_ok if n_ok else None,
    )
    pp_done = _payload_has_postprocess(data)
    save_payload(
        study_name=name,
        out_data=data,
        n_complete=n_done,
        pp_status="done" if pp_done else "idle",
        job_id=job_id,
    )
    return {"ok": True, "study_name": name, "n": payload_row_count(data), "pp": pp_done}

    n_c = int(
        data.get("optuna_n_complete")
        or data.get("optuna_trials_completed")
        or payload_row_count(data)
        or 0
    )
    st_goal = parse_study_trials_value(data.get("optuna_study_trials"))
    upsert_counts(
        study_name=name,
        strategy=str(data.get("strategy") or ""),
        mode=str(data.get("mode") or ""),
        start=str(data.get("start") or ""),
        end=str(data.get("end") or ""),
        job_id=job_id,
        symbol=str(data.get("symbol") or ""),
        study_trials=st_goal,
        n_complete=n_c,
        n_running=0,
    )
    pp_done = _payload_has_postprocess(data)
    save_payload(
        study_name=name,
        out_data=data,
        n_complete=n_c,
        pp_status="done" if pp_done else "idle",
        job_id=job_id,
    )
    return {"ok": True, "study_name": name, "n": payload_row_count(data), "pp": pp_done}


def dedupe_by_read_pairs(
    rows: Sequence[Dict[str, Any]],
    pairs: Optional[Sequence[ReadPair]] = None,
    *,
    ticks: Optional[Sequence[Dict[str, Any]]] = None,
    tf_min: int = 1,
    missing_policy: str = "hole",
    live_cover: bool = False,
    stats: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
    """candle_time 1행 — pairs 앞쪽이 이김. source/channel 은 결과에 남기지 않음.

    ticks 가 있으면 WS 봉만 쓰레기(0건·봉끝 나이초과)일 때 다음 쌍. 한 봉 혼합 없음.
    live_cover=True 이면 링이 그 분을 커버 못할 때 그 WS 봉을 유지.
    stats 가 있으면 pick/garbage/hole 누적 (옵투나 로그용).
    """
    from kis_trader.engine.feed_fallback import bar_is_garbage, live_bar_is_garbage

    order = tuple(pairs or live_read_pairs())
    by_ct: Dict[str, Dict[ReadPair, Dict[str, Any]]] = {}
    for row in rows:
        ct = str(row.get("candle_time") or "")[:12]
        if not ct:
            continue
        src, ch = normalize_source_channel(
            str(row.get("source") or ""),
            str(row.get("channel") or ""),
        )
        by_ct.setdefault(ct, {})
        pair = (src, ch)
        if pair not in by_ct[ct]:
            by_ct[ct][pair] = dict(row)

    tf = max(1, int(tf_min or 1))
    tick_idx = None
    if ticks is not None:
        tick_idx = _index_ticks_by_minute(ticks)
    if stats is not None:
        stats.setdefault("slots", 0)
        stats.setdefault("picked", 0)
        stats.setdefault("hole", 0)
        stats.setdefault("garbage_skip", 0)
        stats.setdefault("pick_by", {})
        stats.setdefault("garbage_by", {})
        stats.setdefault("raw_by", {})
        for ct_map in by_ct.values():
            for pair in ct_map:
                key = "%s/%s" % pair
                stats["raw_by"][key] = int(stats["raw_by"].get(key) or 0) + 1
    out: List[Dict[str, Any]] = []
    for ct in sorted(by_ct.keys()):
        if stats is not None:
            stats["slots"] = int(stats.get("slots") or 0) + 1
        picked: Optional[Dict[str, Any]] = None
        pick_key = ""
        for pair in order:
            bar = by_ct[ct].get(pair)
            if bar is None:
                continue
            src, ch = pair
            if ticks is not None and ch == CHANNEL_WS:
                scoped = _ticks_in_bar_from_index(tick_idx, src, ct, tf)
                if live_cover:
                    bad = live_bar_is_garbage(
                        ticks, candle_time=ct, tf_min=tf, source=src,
                    )
                else:
                    bad = bar_is_garbage(
                        scoped, candle_time=ct, tf_min=tf, source=src,
                        missing_policy=missing_policy,
                    )
                if bad:
                    if stats is not None:
                        stats["garbage_skip"] = int(stats.get("garbage_skip") or 0) + 1
                        gk = "%s/%s" % pair
                        stats["garbage_by"][gk] = int(stats["garbage_by"].get(gk) or 0) + 1
                    continue
            picked = dict(bar)
            pick_key = "%s/%s" % pair
            break
        if picked is None:
            if stats is not None:
                stats["hole"] = int(stats.get("hole") or 0) + 1
            continue
        if stats is not None:
            stats["picked"] = int(stats.get("picked") or 0) + 1
            stats["pick_by"][pick_key] = int(stats["pick_by"].get(pick_key) or 0) + 1
        picked.pop("source", None)
        picked.pop("channel", None)
        out.append(picked)
    return out

def dedupe_by_read_pairs(
    rows: Sequence[Dict[str, Any]],
    pairs: Optional[Sequence[ReadPair]] = None,
    *,
    ticks: Optional[Sequence[Dict[str, Any]]] = None,
    tf_min: int = 1,
    missing_policy: str = "hole",
    live_cover: bool = False,
) -> List[Dict[str, Any]]:
    """candle_time 1행 — pairs 앞쪽이 이김. source/channel 은 결과에 남기지 않음.

    ticks 가 있으면 WS 봉만 쓰레기(0건·봉끝 나이초과)일 때 다음 쌍. 한 봉 혼합 없음.
    live_cover=True 이면 링이 그 분을 커버 못할 때 그 WS 봉을 유지.
    """
    from kis_trader.engine.feed_fallback import bar_is_garbage, live_bar_is_garbage

    order = tuple(pairs or live_read_pairs())
    by_ct: Dict[str, Dict[ReadPair, Dict[str, Any]]] = {}
    for row in rows:
        ct = str(row.get("candle_time") or "")[:12]
        if not ct:
            continue
        src, ch = normalize_source_channel(
            str(row.get("source") or ""),
            str(row.get("channel") or ""),
        )
        by_ct.setdefault(ct, {})
        pair = (src, ch)
        if pair not in by_ct[ct]:
            by_ct[ct][pair] = dict(row)

    tf = max(1, int(tf_min or 1))
    tick_idx = None
    if ticks is not None:
        tick_idx = _index_ticks_by_minute(ticks)
    out: List[Dict[str, Any]] = []
    for ct in sorted(by_ct.keys()):
        picked: Optional[Dict[str, Any]] = None
        for pair in order:
            bar = by_ct[ct].get(pair)
            if bar is None:
                continue
            src, ch = pair
            if ticks is not None and ch == CHANNEL_WS:
                scoped = _ticks_in_bar_from_index(tick_idx, src, ct, tf)
                if live_cover:
                    bad = live_bar_is_garbage(
                        ticks, candle_time=ct, tf_min=tf, source=src,
                    )
                else:
                    bad = bar_is_garbage(
                        scoped, candle_time=ct, tf_min=tf, source=src,
                        missing_policy=missing_policy,
                    )
                if bad:
                    continue
            picked = dict(bar)
            break
        if picked is None:
            continue
        picked.pop("source", None)
        picked.pop("channel", None)
        out.append(picked)
    return out

    ticks_map = _load_ticks_by_code_bulk(
        db, start_key, end_key, market=mk or None, codes_filter=want or None,
    )
    out: Dict[str, List[Dict[str, Any]]] = {}
    stats: Dict[str, Any] = {
        "slots": 0, "picked": 0, "hole": 0, "garbage_skip": 0,
        "pick_by": {}, "garbage_by": {}, "raw_by": {},
    }
    for code, raw in by_code.items():
        ticks = None if ticks_map is None else ticks_map.get(code, [])
        out[code] = dedupe_by_read_pairs(
            raw, pairs, ticks=ticks, tf_min=int(timeframe),
            missing_policy="hole", live_cover=False, stats=stats,
        )
    elapsed = time.perf_counter() - t0
    n_tick_rows = 0
    if ticks_map:
        n_tick_rows = sum(len(v) for v in ticks_map.values())
    slots = int(stats.get("slots") or 0) or 1
    pick_by = stats.get("pick_by") or {}
    garb_by = stats.get("garbage_by") or {}
    raw_by = stats.get("raw_by") or {}
    pick_txt = " ".join(
        "%s=%s(%.0f%%)" % (k, v, 100.0 * int(v) / max(1, int(stats.get("picked") or 1)))
        for k, v in sorted(pick_by.items(), key=lambda x: -int(x[1]))
    ) or "—"
    garb_txt = " ".join(
        "%s=%s" % (k, v) for k, v in sorted(garb_by.items(), key=lambda x: -int(x[1]))
    ) or "0"
    raw_txt = " ".join(
        "%s=%s" % (k, v) for k, v in sorted(raw_by.items(), key=lambda x: -int(x[1]))
    ) or "—"
    logger.info(
        "📂 캔들 bulk: 봉 %s행 · 틱 %s행 · 종목 %s · %.2fs (tf=%s market=%s)",
        len(rows or []),
        n_tick_rows,
        len(out),
        elapsed,
        int(timeframe),
        mk or "ALL",
    )
    logger.info(
        "📊 봉 pick 비율(1차→2차→REST): 슬롯 %s · pick %s · hole %s(%.1f%%) · "
        "쓰레기스킵 %s(슬롯대비 %.1f%%) | raw[%s] | pick[%s] | garbage[%s]",
        slots,
        stats.get("picked"),
        stats.get("hole"),
        100.0 * int(stats.get("hole") or 0) / slots,
        stats.get("garbage_skip"),
        100.0 * int(stats.get("garbage_skip") or 0) / slots,
        raw_txt,
        pick_txt,
        garb_txt,
    )
    return out

    ticks_map = _load_ticks_by_code_bulk(
        db, start_key, end_key, market=mk or None, codes_filter=want or None,
    )
    out: Dict[str, List[Dict[str, Any]]] = {}
    for code, raw in by_code.items():
        ticks = None if ticks_map is None else ticks_map.get(code, [])
        out[code] = dedupe_by_read_pairs(
            raw, pairs, ticks=ticks, tf_min=int(timeframe),
            missing_policy="hole", live_cover=False,
        )
    elapsed = time.perf_counter()
    n_tick_rows = 0
    if ticks_map:
        n_tick_rows = sum(len(v) for v in ticks_map.values())
    logger.info(
        "📂 캔들 bulk: 봉 %s행 · 틱 %s행 · 종목 %s · %.2fs (tf=%s market=%s)",
        len(rows or []),
        n_tick_rows,
        len(out),
        elapsed,
        int(timeframe),
        mk or "ALL",
    )
    return out


    ticks_map = _load_ticks_by_code_bulk(
        db, start_key, end_key, market=mk or None, codes_filter=want or None,
    )
    out: Dict[str, List[Dict[str, Any]]] = {}
    stats: Dict[str, Any] = {
        "slots": 0, "picked": 0, "hole": 0, "garbage_skip": 0,
        "pick_by": {}, "garbage_by": {}, "raw_by": {},
    }
    for code, raw in by_code.items():
        ticks = None if ticks_map is None else ticks_map.get(code, [])
        out[code] = dedupe_by_read_pairs(
            raw, pairs, ticks=ticks, tf_min=int(timeframe),
            missing_policy="hole", live_cover=False, stats=stats,
        )
    elapsed = time.perf_counter() - t0
    n_tick_rows = 0
    if ticks_map:
        n_tick_rows = sum(len(v) for v in ticks_map.values())
    slots = int(stats.get("slots") or 0) or 1
    pick_by = stats.get("pick_by") or {}
    garb_by = stats.get("garbage_by") or {}
    raw_by = stats.get("raw_by") or {}
    pick_txt = " ".join(
        "%s=%s(%.0f%%)" % (k, v, 100.0 * int(v) / max(1, int(stats.get("picked") or 1)))
        for k, v in sorted(pick_by.items(), key=lambda x: -int(x[1]))
    ) or "—"
    garb_txt = " ".join(
        "%s=%s" % (k, v) for k, v in sorted(garb_by.items(), key=lambda x: -int(x[1]))
    ) or "0"
    raw_txt = " ".join(
        "%s=%s" % (k, v) for k, v in sorted(raw_by.items(), key=lambda x: -int(x[1]))
    ) or "—"
    logger.info(
        "📂 캔들 bulk: 봉 %s행 · 틱 %s행 · 종목 %s · %.2fs (tf=%s market=%s)",
        len(rows or []),
        n_tick_rows,
        len(out),
        elapsed,
        int(timeframe),
        mk or "ALL",
    )
    logger.info(
        "📊 봉 pick 비율(1차→2차→REST): 슬롯 %s · pick %s · hole %s(%.1f%%) · "
        "쓰레기스킵 %s(슬롯대비 %.1f%%) | raw[%s] | pick[%s] | garbage[%s]",
        slots,
        stats.get("picked"),
        stats.get("hole"),
        100.0 * int(stats.get("hole") or 0) / slots,
        stats.get("garbage_skip"),
        100.0 * int(stats.get("garbage_skip") or 0) / slots,
        raw_txt,
        pick_txt,
        garb_txt,
    )
    return out

def fetch_ws_candles_warmup_before(

    ticks_map = _load_ticks_by_code_bulk(
        db, start_key, end_key, market=mk or None, codes_filter=want or None,
    )
    out: Dict[str, List[Dict[str, Any]]] = {}
    for code, raw in by_code.items():
        ticks = None if ticks_map is None else ticks_map.get(code, [])
        out[code] = dedupe_by_read_pairs(
            raw, pairs, ticks=ticks, tf_min=int(timeframe),
            missing_policy="hole", live_cover=False,
        )
    elapsed = time.perf_counter() - t0
    n_tick_rows = 0
    if ticks_map:
        n_tick_rows = sum(len(v) for v in ticks_map.values())
    logger.info(
        "📂 캔들 bulk: 봉 %s행 · 틱 %s행 · 종목 %s · %.2fs (tf=%s market=%s)",
        len(rows or []),
        n_tick_rows,
        len(out),
        elapsed,
        int(timeframe),
        mk or "ALL",
    )
    return out

def fetch_ws_candles_warmup_before(

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
# Repair leftover for today's studies that have finished>=target but note says 남은
import json
from pathlib import Path
from kis_trader.backtest.optuna_study_store import ingest_out_data, flags_for_web, load_row

paths = [
 'kis_trader/backtest/results/optuna_momentum_tpe_20260821_104243.json',
 'kis_trader/backtest/results/optuna_scalp_tpe_20260821_122053.json',
 'kis_trader/backtest/results/optuna_tail_tpe_20260821_113102.json',
 'kis_trader/backtest/results/optuna_tail_tpe_20260821_111355.json',
 'kis_trader/backtest/results/optuna_breakout_tpe_20260821_120015.json',
 'kis_trader/backtest/results/optuna_breakout_tpe_20260821_114315.json',
]
for p in paths:
    path = Path(p)
    if not path.is_file():
        print('missing', p); continue
    data = json.loads(path.read_text(encoding='utf-8'))
    name = data.get('optuna_study_name')
    r = ingest_out_data(data, job_id='', replace=True)
    fl = flags_for_web(name)
    print(name, 'finished', data.get('optuna_n_finished'), 'complete', data.get('optuna_n_complete'),
          '→ leftover', fl.get('leftover_trials'), fl.get('leftover_note'), 'can_continue', fl.get('can_continue'))
PY
python3 -m py_compile kis_trader/backtest/optuna_study_store.py kis_trader/backtest/bt_candle_source.py kis_trader/ws/candle_series.py

Repair leftover notes and compile changed files


sudo systemctl restart kis_backtest_web.service && sleep 2 && systemctl is-active kis_backtest_web.service && curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/ && cd /home/hoon/kis_bot && python3 - <<'PY'
# mini bulk stats log smoke (few codes)
import logging
logging.basicConfig(level=logging.INFO)
from database import TradeDB
from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk, list_ws_candle_codes
db=TradeDB()
codes=list_ws_candle_codes(db,1,'202608180900','202608201530',market='KR')[:5]
out=fetch_ws_candles_by_code_bulk(db,1,'202608180900','202608201530',market='KR',codes_filter=codes)
print('codes', len(out), 'bars', sum(len(v) for v in out.values()))
PY

Restart web + smoke candle pick-ratio log


### 1. 이어돌리기가 왜 나왔나 → **버그였고 고침**

스터디는 **시도 200** (COMPLETE 188 + pruned 12)인데, JSON import가 **COMPLETE 188만** 남은횟수에 넣어서 “남은 12회”가 떴습니다.

- 고침: `optuna_n_finished` 기준으로 leftover/`can_continue`
- 오늘 돌린 모멘텀·꼬리·돌파·스캘프 **이미 수리** → `목표 도달 · can_continue=False`
- 웹 재시작됨 → Optuna 탭 새로고침하면 이어돌리기 사라져야 함

---

### 2. 기본은 마이너스, 후처리에서 플러스 · 건수 급감

**로직이 깨진 게 아니라 설계대로입니다.**

| 단계 | 의미 |
|------|------|
| TPE (차트만, 호가 OFF) | 거래 많고 필터 약함 → PnL이 밋밋하거나 − |
| 후처리 `100 진입만` 등 | **호가 통과한 체결만** 남김 → 30→12→4건처럼 줄어듦 |

“확실한 것만”에 가깝습니다. 다만 표본이 4건이면 **우연히 잘 나온 숫자**일 수 있어, 그대로 실매 넣기엔 위험합니다.

합의 방 `100` 잔량≥0.85 ≈ 지금 실매 0.86과 비슷한 축입니다.

---

### 3. 며칠 전보다 안 좋은 게 로직 버그냐?

**엔진을 이상하게 바꿔서라기보다 기간·데이터가 바뀐 영향이 큽니다.**

- 예전 +구간: **8/12~14** (틱 듀얼 전·부분 / 지금은 그 날 **삭제**)
- 지금: **8/18~20** 3일만 · 과적합 요약도 “장일 +16”로 경고 중
- 실매 동기간 −5만 vs 백테 gated +2만 → 유니버스·고스트·틱 경로 차로 **원래 다를 수 있음** (브리핑에도 적혀 있음)

“로직이 이상하게 바뀌었다”고 단정할 증거는 없고, **짧은 3일 + 깨끗한 데이터 구간이 아직 짧다**가 맞습니다.

---

### 4. 1·2·3차 / 쓰레기 비율 로그

다음 Optuna 캔들 로드부터 이런 줄이 찍힙니다.

```text
📊 봉 pick 비율(1차→2차→REST): 슬롯 … · 쓰레기스킵 …% | raw[…] | pick[…] | garbage[…]
```

스모크(5종목 8/18~20) 예: raw는 REST 대부분, WS 쓰레기스킵 약 1.9%, pick은 거의 `kiwoom/rest`.

---

**요약:** 이어돌리기=표시 버그(수정·수리 완료). −→후처리+·건수↓=호가필터 정상. 예전보다 약함=기간/데이터 쪽을 먼저 의. 다음 옵투나 로그에서 pick/쓰레기 비율 확인하면 됩니다.


<timestamp>Friday, Aug 21, 2026, 2:33 PM (UTC+9)</timestamp>
<user_query>
봉픽 말고 트리거도 로그를 남겨야지 틱 현재가 현재호가 틱타임 전부 추적할 수 있는건 다 넣엉야돼 1차가 뭔지 2차가 뭔지 이런것도 kis 인지  kiwoom인지 ls인지
</user_query>


트리거 시점에 틱·호가·시각·1/2/3차 벤더가 남도록, 기존 로그·피드 읽기 경로부터 확인합니다.


트리거 로그에 틱가·호가·시각·벤더(1/2/3차)를 붙이도록, `get_price`/`호가` 기억값과 `_scan_log`를 확장합니다.


StrReplace

def format_mm_feed_line(tick_lab: str, ob_lab: str) -> str:
    t = str(tick_lab or "").strip() or "?"
    o = str(ob_lab or "").strip() or "?"
    return f"시세: {t} | 호가: {o}"

def trigger_feed_detail_log_enabled() -> bool:
    """트리거(매수체크) 로그에 틱·호가 상세 추적 줄. 기본 ON."""
    try:
        return bool(get_env_bool("TRIGGER_FEED_DETAIL_LOG", True))
    except Exception:
        return True

def extract_tick_trace_fields(data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    """get_price dict → 로그용 현재가·틱타임·나이."""
    out: Dict[str, Any] = {}
    if not isinstance(data, dict):
        return out
    px = (
        data.get("stck_prpr")
        or data.get("price")
        or data.get("cur_prc")
        or data.get("close")
        or 0
    )
    try:
        out["price"] = float(str(px).replace(",", "") or 0)
    except (TypeError, ValueError):
        out["price"] = 0.0
    tt = (
        data.get("tick_time")
        or data.get("tick_time_raw")
        or data.get("chetime")
        or data.get("cntr_tm")
        or data.get("FID20")
        or data.get("stck_cntg_hour")
        or data.get("cntg_hour")
        or ""
    )
    out["tick_time"] = str(tt or "").strip()
    if data.get("_age_ms") is not None:
        try:
            out["age_ms"] = int(data.get("_age_ms") or 0)
        except (TypeError, ValueError):
            pass
    lag = packet_lag_seconds(out.get("tick_time") or "")
    if lag is not None:
        out["lag_sec"] = round(float(lag), 2)
    if data.get("_feed_vendor"):
        out["vendor"] = str(data.get("_feed_vendor") or "").strip().lower()
    return out

def extract_ob_trace_fields(snap: Any) -> Dict[str, Any]:
    """호가 스냅샷 → bid/ask/스프레드/잔량비·스냅시각."""
    out: Dict[str, Any] = {}
    if snap is None:
        return out
    try:
        if hasattr(snap, "best_bid"):
            out["best_bid"] = int(snap.best_bid() or 0)
            out["best_ask"] = int(snap.best_ask() or 0)
            try:
                out["spread_pct"] = round(float(snap.spread_pct() or 0), 3)
            except Exception:
                pass
            try:
                bq = int(getattr(snap, "total_bid_qty", 0) or 0)
                aq = int(getattr(snap, "total_ask_qty", 0) or 0)
                out["bid_qty"] = bq
                out["ask_qty"] = aq
                if aq > 0:
                    out["bid_ask_ratio"] = round(bq / float(aq), 3)
            except Exception:
                pass
            out["snap_time"] = str(getattr(snap, "snap_time", "") or "").strip()
            src = str(getattr(snap, "source", "") or "").strip()
            if src:
                out["ob_source"] = src
            if getattr(snap, "ts", None):
                try:
                    out["age_ms"] = int(max(0.0, (time.time() - float(snap.ts)) * 1000))
                except Exception:
                    pass
            return out
    except Exception:
        pass
    if isinstance(snap, dict):
        try:
            out["best_bid"] = int(float(snap.get("best_bid") or snap.get("bidp1") or 0))
            out["best_ask"] = int(float(snap.get("best_ask") or snap.get("askp1") or 0))
        except (TypeError, ValueError):
            pass
        out["snap_time"] = str(snap.get("snap_time") or "").strip()
    return out

def format_trigger_feed_trace(
    tick_rec: Optional[Dict[str, Any]],
    ob_rec: Optional[Dict[str, Any]],
    *,
    tick_primary: str = "",
    ob_primary: str = "",
) -> str:
    """매수체크 로그 꼬리 — 벤더(1·2·3차) + 현재가 + 틱타임 + 호가."""
    import time as _time  # noqa: F401 — age already in rec

    tp = str(tick_primary or live_tick_primary()).strip().lower() or "kiwoom"
    op = str(ob_primary or live_ob_primary()).strip().lower() or "kiwoom"
    alt_t = "kiwoom" if tp == "kis" else "kis"
    alt_o = "kiwoom" if op == "kis" else "kis"
    parts: List[str] = [
        f"틱1차설정={tp}(체인 {tp}→{alt_t}→ls)",
        f"호가1차설정={op}(체인 {op}→{alt_o}→ls)",
    ]
    tr = tick_rec or {}
    if tr.get("label") or tr.get("vendor"):
        lab = str(tr.get("label") or format_vendor_label(
            str(tr.get("vendor") or ""),
            int(tr.get("tier") or tick_feed_tier(str(tr.get("vendor") or ""), tp)),
            bool(tr.get("spilled")),
        ))
        bit = [f"틱실제={lab}"]
        if tr.get("price"):
            try:
                bit.append("px=%s" % int(float(tr.get("price") or 0)))
            except (TypeError, ValueError):
                bit.append("px=%s" % tr.get("price"))
        if tr.get("tick_time"):
            bit.append("t=%s" % tr.get("tick_time"))
        if tr.get("lag_sec") is not None:
            bit.append("lag=%ss" % tr.get("lag_sec"))
        elif tr.get("age_ms") is not None:
            bit.append("age=%sms" % tr.get("age_ms"))
        parts.append(" ".join(bit))
    else:
        parts.append("틱실제=없음")
    obr = ob_rec or {}
    if obr.get("label") or obr.get("vendor"):
        lab = str(obr.get("label") or format_vendor_label(
            str(obr.get("vendor") or ""),
            int(obr.get("tier") or tick_feed_tier(str(obr.get("vendor") or ""), op)),
            bool(obr.get("spilled")),
        ))
        bit = [f"호가실제={lab}"]
        if obr.get("best_bid") or obr.get("best_ask"):
            bit.append("bid=%s ask=%s" % (obr.get("best_bid") or 0, obr.get("best_ask") or 0))
        if obr.get("spread_pct") is not None:
            bit.append("spr=%s%%" % obr.get("spread_pct"))
        if obr.get("bid_ask_ratio") is not None:
            bit.append("or=%s" % obr.get("bid_ask_ratio"))
        if obr.get("snap_time"):
            bit.append("snap=%s" % obr.get("snap_time"))
        if obr.get("ob_source"):
            bit.append("src=%s" % obr.get("ob_source"))
        if obr.get("age_ms") is not None:
            bit.append("age=%sms" % obr.get("age_ms"))
        parts.append(" ".join(bit))
    else:
        parts.append("호가실제=없음")
    return " | ".join(parts)

def _tick_second_key(tick: Dict[str, Any]) -> str:

def format_mm_feed_line(tick_lab: str, ob_lab: str) -> str:
    t = str(tick_lab or "").strip() or "?"
    o = str(ob_lab or "").strip() or "?"
    return f"시세: {t} | 호가: {o}"

def _tick_second_key(tick: Dict[str, Any]) -> str:


Grep

^def _tick_second_key|^import time|^from datetime


StrReplace

from datetime import datetime
import time
from typing import Any, Dict, List, Optional, Sequence, Tuple

from datetime import datetime
from typing import Any, Dict, List, Optional, Sequence, Tuple

def format_trigger_feed_trace(
    tick_rec: Optional[Dict[str, Any]],
    ob_rec: Optional[Dict[str, Any]],
    *,
    tick_primary: str = "",
    ob_primary: str = "",
) -> str:
    """매수체크 로그 꼬리 — 벤더(1·2·3차) + 현재가 + 틱타임 + 호가."""
    tp = str(tick_primary or live_tick_primary()).strip().lower() or "kiwoom"
    op = str(ob_primary or live_ob_primary()).strip().lower() or "kiwoom"
    alt_t = "kiwoom" if tp == "kis" else "kis"
    alt_o = "kiwoom" if op == "kis" else "kis"
    parts: List[str] = [
        f"틱1차설정={tp}(체인 {tp}→{alt_t}→ls)",
        f"호가1차설정={op}(체인 {op}→{alt_o}→ls)",
    ]
    tr = tick_rec or {}
    if tr.get("label") or tr.get("vendor"):
        lab = str(tr.get("label") or format_vendor_label(
            str(tr.get("vendor") or ""),
            int(tr.get("tier") or tick_feed_tier(str(tr.get("vendor") or ""), tp)),
            bool(tr.get("spilled")),
        ))
        bit = [f"틱실제={lab}"]
        if tr.get("price"):
            try:
                bit.append("px=%s" % int(float(tr.get("price") or 0)))
            except (TypeError, ValueError):
                bit.append("px=%s" % tr.get("price"))
        if tr.get("tick_time"):
            bit.append("t=%s" % tr.get("tick_time"))
        if tr.get("lag_sec") is not None:
            bit.append("lag=%ss" % tr.get("lag_sec"))
        elif tr.get("age_ms") is not None:
            bit.append("age=%sms" % tr.get("age_ms"))
        parts.append(" ".join(bit))
    else:
        parts.append("틱실제=없음")
    obr = ob_rec or {}
    if obr.get("label") or obr.get("vendor"):
        lab = str(obr.get("label") or format_vendor_label(
            str(obr.get("vendor") or ""),
            int(obr.get("tier") or tick_feed_tier(str(obr.get("vendor") or ""), op)),
            bool(obr.get("spilled")),
        ))
        bit = [f"호가실제={lab}"]
        if obr.get("best_bid") or obr.get("best_ask"):
            bit.append("bid=%s ask=%s" % (obr.get("best_bid") or 0, obr.get("best_ask") or 0))
        if obr.get("spread_pct") is not None:
            bit.append("spr=%s%%" % obr.get("spread_pct"))
        if obr.get("bid_ask_ratio") is not None:
            bit.append("or=%s" % obr.get("bid_ask_ratio"))
        if obr.get("snap_time"):
            bit.append("snap=%s" % obr.get("snap_time"))
        if obr.get("ob_source"):
            bit.append("src=%s" % obr.get("ob_source"))
        if obr.get("age_ms") is not None:
            bit.append("age=%sms" % obr.get("age_ms"))
        parts.append(" ".join(bit))
    else:
        parts.append("호가실제=없음")
    return " | ".join(parts)

def _tick_second_key(tick: Dict[str, Any]) -> str:

def format_trigger_feed_trace(
    tick_rec: Optional[Dict[str, Any]],
    ob_rec: Optional[Dict[str, Any]],
    *,
    tick_primary: str = "",
    ob_primary: str = "",
) -> str:
    """매수체크 로그 꼬리 — 벤더(1·2·3차) + 현재가 + 틱타임 + 호가."""
    import time as _time  # noqa: F401 — age already in rec

    tp = str(tick_primary or live_tick_primary()).strip().lower() or "kiwoom"
    op = str(ob_primary or live_ob_primary()).strip().lower() or "kiwoom"
    alt_t = "kiwoom" if tp == "kis" else "kis"
    alt_o = "kiwoom" if op == "kis" else "kis"
    parts: List[str] = [
        f"틱1차설정={tp}(체인 {tp}→{alt_t}→ls)",
        f"호가1차설정={op}(체인 {op}→{alt_o}→ls)",
    ]
    tr = tick_rec or {}
    if tr.get("label") or tr.get("vendor"):
        lab = str(tr.get("label") or format_vendor_label(
            str(tr.get("vendor") or ""),
            int(tr.get("tier") or tick_feed_tier(str(tr.get("vendor") or ""), tp)),
            bool(tr.get("spilled")),
        ))
        bit = [f"틱실제={lab}"]
        if tr.get("price"):
            try:
                bit.append("px=%s" % int(float(tr.get("price") or 0)))
            except (TypeError, ValueError):
                bit.append("px=%s" % tr.get("price"))
        if tr.get("tick_time"):
            bit.append("t=%s" % tr.get("tick_time"))
        if tr.get("lag_sec") is not None:
            bit.append("lag=%ss" % tr.get("lag_sec"))
        elif tr.get("age_ms") is not None:
            bit.append("age=%sms" % tr.get("age_ms"))
        parts.append(" ".join(bit))
    else:
        parts.append("틱실제=없음")
    obr = ob_rec or {}
    if obr.get("label") or obr.get("vendor"):
        lab = str(obr.get("label") or format_vendor_label(
            str(obr.get("vendor") or ""),
            int(obr.get("tier") or tick_feed_tier(str(obr.get("vendor") or ""), op)),
            bool(obr.get("spilled")),
        ))
        bit = [f"호가실제={lab}"]
        if obr.get("best_bid") or obr.get("best_ask"):
            bit.append("bid=%s ask=%s" % (obr.get("best_bid") or 0, obr.get("best_ask") or 0))
        if obr.get("spread_pct") is not None:
            bit.append("spr=%s%%" % obr.get("spread_pct"))
        if obr.get("bid_ask_ratio") is not None:
            bit.append("or=%s" % obr.get("bid_ask_ratio"))
        if obr.get("snap_time"):
            bit.append("snap=%s" % obr.get("snap_time"))
        if obr.get("ob_source"):
            bit.append("src=%s" % obr.get("ob_source"))
        if obr.get("age_ms") is not None:
            bit.append("age=%sms" % obr.get("age_ms"))
        parts.append(" ".join(bit))
    else:
        parts.append("호가실제=없음")
    return " | ".join(parts)

def _tick_second_key(tick: Dict[str, Any]) -> str:


    def _remember_feed_read(
        self, kind: str, code: str, vendor: str, *, spilled: bool = False,
        meta: Optional[dict] = None,
    ) -> None:
        code = (code or "").strip()
        v = self._normalize_feed_vendor(vendor, vendor or "")
        if not code or not v:
            return
        try:
            from kis_trader.engine.feed_fallback import tick_feed_tier, format_vendor_label
            primary = (
                self._normalize_feed_vendor(
                    get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom",
                    "kiwoom",
                )
                if kind == "ob"
                else self._normalize_feed_vendor(
                    get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom",
                    "kiwoom",
                )
            )
            tier = tick_feed_tier(v, primary)
            rec = {
                "vendor": v,
                "tier": tier,
                "spilled": bool(spilled),
                "label": format_vendor_label(v, tier, spilled),
                "ts": time.time(),
            }
        except Exception:
            rec = {"vendor": v, "tier": 0, "spilled": bool(spilled), "label": v, "ts": time.time()}
        if isinstance(meta, dict):
            for k, val in meta.items():
                if k in ("vendor", "tier", "spilled", "label", "ts"):
                    continue
                if val is None or val == "":
                    continue
                rec[k] = val
        with self._feed_read_lock:
            if kind == "ob":
                self._last_ob_read[code] = rec
            else:
                self._last_tick_read[code] = rec

    def _remember_feed_read(
        self, kind: str, code: str, vendor: str, *, spilled: bool = False,
    ) -> None:
        code = (code or "").strip()
        v = self._normalize_feed_vendor(vendor, vendor or "")
        if not code or not v:
            return
        try:
            from kis_trader.engine.feed_fallback import tick_feed_tier, format_vendor_label
            primary = (
                self._normalize_feed_vendor(
                    get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom",
                    "kiwoom",
                )
                if kind == "ob"
                else self._normalize_feed_vendor(
                    get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom",
                    "kiwoom",
                )
            )
            tier = tick_feed_tier(v, primary)
            rec = {
                "vendor": v,
                "tier": tier,
                "spilled": bool(spilled),
                "label": format_vendor_label(v, tier, spilled),
                "ts": time.time(),
            }
        except Exception:
            rec = {"vendor": v, "tier": 0, "spilled": bool(spilled), "label": v, "ts": time.time()}
        with self._feed_read_lock:
            if kind == "ob":
                self._last_ob_read[code] = rec
            else:
                self._last_tick_read[code] = rec


    def get_ob_feed_label(self, code: str) -> str:
        """매수/매도 MM 표기용 호가 소스."""
        code = (code or "").strip()
        with self._feed_read_lock:
            rec = dict(self._last_ob_read.get(code) or {})
        return self._label_from_read(rec, kind="ob", code=code)

    def get_trigger_feed_trace(self, code: str) -> str:
        """매수체크 로그용 — 1차설정·실제 벤더·현재가·틱타임·호가 한 줄."""
        code = (code or "").strip()
        if not code:
            return ""
        try:
            from kis_trader.engine.feed_fallback import (
                format_trigger_feed_trace,
                live_ob_primary,
                live_tick_primary,
                trigger_feed_detail_log_enabled,
            )
            if not trigger_feed_detail_log_enabled():
                return ""
            with self._feed_read_lock:
                tick = dict(self._last_tick_read.get(code) or {})
                ob = dict(self._last_ob_read.get(code) or {})
            return format_trigger_feed_trace(
                tick, ob,
                tick_primary=live_tick_primary(),
                ob_primary=live_ob_primary(),
            )
        except Exception:
            return ""

    def _vendor_price(self, vendor: str, code: str, max_age_sec: Optional[float]):

    def get_ob_feed_label(self, code: str) -> str:
        """매수/매도 MM 표기용 호가 소스."""
        code = (code or "").strip()
        with self._feed_read_lock:
            rec = dict(self._last_ob_read.get(code) or {})
        return self._label_from_read(rec, kind="ob", code=code)

    def _vendor_price(self, vendor: str, code: str, max_age_sec: Optional[float]):

        for v in chain:
            p = self._vendor_price(v, code, resolved_age)
            if p:
                if isinstance(p, dict):
                    p = dict(p)
                    p["_feed_vendor"] = v
                try:
                    from kis_trader.engine.feed_fallback import extract_tick_trace_fields
                    meta = extract_tick_trace_fields(p if isinstance(p, dict) else None)
                except Exception:
                    meta = None
                self._remember_feed_read(
                    "tick", code, v, spilled=bool(spilled and v == home), meta=meta,
                )
                return p
        return None

        for v in chain:
            p = self._vendor_price(v, code, resolved_age)
            if p:
                if isinstance(p, dict):
                    p = dict(p)
                    p["_feed_vendor"] = v
                self._remember_feed_read(
                    "tick", code, v, spilled=bool(spilled and v == home),
                )
                return p
        return None

        for v in chain:
            snap = self._vendor_orderbook_snap(v, code, float(vendor_age))
            if snap is not None:
                try:
                    setattr(snap, "_feed_vendor", v)
                except Exception:
                    pass
                try:
                    from kis_trader.engine.feed_fallback import extract_ob_trace_fields
                    meta = extract_ob_trace_fields(snap)
                except Exception:
                    meta = None
                self._remember_feed_read(
                    "ob", code, v, spilled=bool(spilled and v == home), meta=meta,
                )
                return snap
        return None

        for v in chain:
            snap = self._vendor_orderbook_snap(v, code, float(vendor_age))
            if snap is not None:
                try:
                    setattr(snap, "_feed_vendor", v)
                except Exception:
                    pass
                self._remember_feed_read(
                    "ob", code, v, spilled=bool(spilled and v == home),
                )
                return snap
        return None


class FeedPrefixLoggerAdapter(logging.LoggerAdapter):
    """탈락/스캔 로그(🔍 [) 앞에 T:설정|O:설정|R:실제읽기 접두어를 붙인다.

    예: 🔍 [탈락-RSI] → 🔍 [T:kis|O:kiwoom|R:kis(1차)|탈락-RSI]
    T/O = LIVE_*_PROVIDER(DB). R = get_tick_feed_label(code) — 직전 get_price 읽기.
    extra={'scan_code': code} 로 R: 활성화 (_scan_log 헬퍼 사용).
    TRIGGER_FEED_DETAIL_LOG(기본 true) 이면 틱가·틱타임·호가·1/2/3차 체인 꼬리 추가.
    """

    def process(self, msg, kwargs):
        if isinstance(msg, str) and "🔍 [" in msg and "시세:" not in msg and "LIVE_TICK_PROVIDER" in self.extra:
            try:
                # 이미 T:|O: 접두가 있으면 중복 삽입 금지 (매수체크 로그 등)
                if "🔍 [T:" not in msg[:32]:
                    tick_p, ob_p = _live_feed_providers()
                    read_lab = ""
                    extra = kwargs.get("extra") or {}
                    scan_code = str(extra.get("scan_code") or "").strip()
                    ws = self.extra.get("ws")
                    if scan_code and ws is not None and hasattr(ws, "get_tick_feed_label"):
                        try:
                            read_lab = str(ws.get_tick_feed_label(scan_code) or "").strip()
                        except Exception:
                            read_lab = ""
                    if read_lab:
                        msg = msg.replace(
                            "🔍 [",
                            f"🔍 [T:{tick_p}|O:{ob_p}|R:{read_lab}|",
                            1,
                        )
                    elif tick_p or ob_p:
                        msg = msg.replace("🔍 [", f"🔍 [T:{tick_p}|O:{ob_p}|", 1)
                    if scan_code and ws is not None and hasattr(ws, "get_trigger_feed_trace"):
                        try:
                            detail = str(ws.get_trigger_feed_trace(scan_code) or "").strip()
                        except Exception:
                            detail = ""
                        if detail and "틱1차설정=" not in msg:
                            msg = f"{msg} · {detail}"
            except Exception:
                pass
        return msg, kwargs

class FeedPrefixLoggerAdapter(logging.LoggerAdapter):
    """탈락/스캔 로그(🔍 [) 앞에 T:설정|O:설정|R:실제읽기 접두어를 붙인다.

    예: 🔍 [탈락-RSI] → 🔍 [T:kis|O:kiwoom|R:kis(1차)|탈락-RSI]
    T/O = LIVE_*_PROVIDER(DB). R = get_tick_feed_label(code) — 직전 get_price 읽기.
    extra={'scan_code': code} 로 R: 활성화 (_scan_log 헬퍼 사용).
    """

    def process(self, msg, kwargs):
        if isinstance(msg, str) and "🔍 [" in msg and "시세:" not in msg and "LIVE_TICK_PROVIDER" in self.extra:
            try:
                # 이미 T:|O: 접두가 있으면 중복 삽입 금지 (매수체크 로그 등)
                if "🔍 [T:" not in msg[:32]:
                    tick_p, ob_p = _live_feed_providers()
                    read_lab = ""
                    extra = kwargs.get("extra") or {}
                    scan_code = str(extra.get("scan_code") or "").strip()
                    if scan_code:
                        ws = self.extra.get("ws")
                        if ws is not None and hasattr(ws, "get_tick_feed_label"):
                            try:
                                read_lab = str(ws.get_tick_feed_label(scan_code) or "").strip()
                            except Exception:
                                read_lab = ""
                    if read_lab:
                        msg = msg.replace(
                            "🔍 [",
                            f"🔍 [T:{tick_p}|O:{ob_p}|R:{read_lab}|",
                            1,
                        )
                    elif tick_p or ob_p:
                        msg = msg.replace("🔍 [", f"🔍 [T:{tick_p}|O:{ob_p}|", 1)
            except Exception:
                pass
        return msg, kwargs


    "LIVE_FEED_FALLBACK_MAX_AGE_SEC",
    "TRIGGER_FEED_DETAIL_LOG",


                _f("LIVE_FEED_FALLBACK_MAX_AGE_SEC", "⏱ 시세·호가 읽기 폴백 나이(초)", "float", default=3.0,
                   hint=(
                       "비교 = 그 틱의 체결시각(키움 FID20 / 한투 chetime) vs 이 서버 지금. "
                       "1차·2차·3차 모두 같음. 보조가 늦으면 3차, 매도는 REST. "
                       "키움 시각에서 한투 시각을 빼서 늦다고 하지 않음. "
                       "넘기면 그 벤더 실패. 적재(공책)와 분리. 기본 3. "
                       "분봉 쓰레기 판정 나이도 이 값(봉 끝시각 대비)."
                   )),
                _f("TRIGGER_FEED_DETAIL_LOG", "📝 트리거 로그에 틱·호가·벤더 상세", "bool", default=True,
                   hint=(
                       "ON=매수체크(🔍) 로그 끝에 틱1차설정/실제(kis|kiwoom|ls)·현재가·틱타임·"
                       "호가 bid/ask/잔량비·스냅시각을 붙임. OFF=접두 T/O/R 만. 재시작 없이 반영(수 초)"
                   )),
                _f("CANDLE_GARBAGE_FALLBACK", "🕯 분봉 쓰레기→2차·3차 봉 통째", "bool", default=True,

                _f("LIVE_FEED_FALLBACK_MAX_AGE_SEC", "⏱ 시세·호가 읽기 폴백 나이(초)", "float", default=3.0,
                   hint=(
                       "비교 = 그 틱의 체결시각(키움 FID20 / 한투 chetime) vs 이 서버 지금. "
                       "1차·2차·3차 모두 같음. 보조가 늦으면 3차, 매도는 REST. "
                       "키움 시각에서 한투 시각을 빼서 늦다고 하지 않음. "
                       "넘기면 그 벤더 실패. 적재(공책)와 분리. 기본 3. "
                       "분봉 쓰레기 판정 나이도 이 값(봉 끝시각 대비)."
                   )),
                _f("CANDLE_GARBAGE_FALLBACK", "🕯 분봉 쓰레기→2차·3차 봉 통째", "bool", default=True,

시세·호가 읽기 폴백 (실매=옵투나 같은 읽기):
- `LIVE_TICK_PROVIDER` / `LIVE_OB_PROVIDER` 운영설정 **셀박** (`kiwoom`|`kis`). LS는 1차 아님(3차 spill).
- `LIVE_FEED_FALLBACK_MAX_AGE_SEC`(기본 3) = **그 틱의 체결시각(FID20/chetime) vs 이 서버 지금**. 1·2·3차 동일. 벤더끼리 시각 비교 금지.
- `TRIGGER_FEED_DETAIL_LOG`(기본 true) = 매수체크 `🔍` 로그에 틱1차설정/실제(kis|kiwoom|ls)·px·틱타임·호가 bid/ask/or 꼬리.
- 분봉 쓰레기=`CANDLE_GARBAGE_FALLBACK`(기본 true). 그 분 그 소스 틱 0건이거나 전부 **봉 끝시각** 대비 읽기나이 초과면 그 WS 봉을 구멍 → 다음 소스 **봉 통째**. 한 봉 안 키움+한투 혼합 금지. 실매 링이 그 소스·그 분을 커버 못하면 유지.

시세·호가 읽기 폴백 (실매=옵투나 같은 읽기):
- `LIVE_TICK_PROVIDER` / `LIVE_OB_PROVIDER` 운영설정 **셀박** (`kiwoom`|`kis`). LS는 1차 아님(3차 spill).
- `LIVE_FEED_FALLBACK_MAX_AGE_SEC`(기본 3) = **그 틱의 체결시각(FID20/chetime) vs 이 서버 지금**. 1·2·3차 동일. 벤더끼리 시각 비교 금지.
- 분봉 쓰레기=`CANDLE_GARBAGE_FALLBACK`(기본 true). 그 분 그 소스 틱 0건이거나 지나면 **봉 끝시각** 대비 읽기나이 초과면 그 WS 봉을 구멍 → 다음 소스 **봉 통째**. 한 봉 안 키움+한투 혼합 금지. 실매 링이 그 소스·그 분을 커버 못하면 유지.


            if cntr_str_val is not None:
                data_compat["cntr_str"] = str(cntr_str_val)
            if tick_time_raw:
                data_compat["kis_cntg_hour_raw"] = cntg_hour_raw
                data_compat["kis_bsop_date_raw"] = bsop_date_raw
                data_compat["tick_time"] = str(tick_time_raw)
                data_compat["chetime"] = str(cntg_hour_raw or "")

            if cntr_str_val is not None:
                data_compat["cntr_str"] = str(cntr_str_val)
            if tick_time_raw:
                data_compat["kis_cntg_hour_raw"] = cntg_hour_raw
                data_compat["kis_bsop_date_raw"] = bsop_date_raw

    out["tick_time"] = str(tt or "").strip()
    if not out["tick_time"]:
        bs = str(data.get("kis_bsop_date_raw") or "").strip()
        hr = str(
            data.get("kis_cntg_hour_raw")
            or data.get("chetime")
            or data.get("kiwoom_fid20")
            or ""
        ).strip()
        if bs and hr:
            out["tick_time"] = (bs + hr)[:14]
        elif hr:
            out["tick_time"] = hr
    # 키움 캐시에 tick_time 키가 없어도 chetime만으로 lag 계산
    if data.get("chetime") and not out.get("tick_time"):
        out["tick_time"] = str(data.get("chetime") or "").strip()
    if data.get("_age_ms") is not None:

    out["tick_time"] = str(tt or "").strip()
    if data.get("_age_ms") is not None:


- `LIVE_FEED_FALLBACK_MAX_AGE_SEC`(기본 3) = **그 틱의 체결시각(FID20/chetime) vs 이 서버 지금**. 1·2·3차 동일. 벤더끼리 시각 비교 금지.
- `TRIGGER_FEED_DETAIL_LOG`(기본 true) = 매수체크 `🔍` 로그에 틱1차설정/실제(kis|kiwoom|ls)·px·틱타임·호가 bid/ask/or 꼬리.
- 분봉 쓰레기=`CANDLE_GARBAGE_FALLBACK`(기본 true). 그 분 그 소스 틱 0건이거나 지나면 **봉 끝시각** 대비 읽기나이 초과면 그 WS 봉을 구멍 → 다음 소스 **봉 통째**. 한 봉 안 키움+한투 혼합 금지. 실매 링이 그 소스·그 분을 커버 못하면 유지.

- `LIVE_FEED_FALLBACK_MAX_AGE_SEC`(기본 3) = **그 틱의 체결시각(FID20/chetime) vs 이 서버 지금**. 1·2·3차 동일. 벤더끼리 시각 비교 금지.
- 분봉 쓰레기=`CANDLE_GARBAGE_FALLBACK`(기본 true). 그 분 그 소스 틱 0건이거나 지나면 **봉 끝시각** 대비 읽기나이 초과면 그 WS 봉을 구멍 → 다음 소스 **봉 통째**. 한 봉 안 키움+한투 혼합 금지. 실매 링이 그 소스·그 분을 커버 못하면 유지.