// ──────────────────────────────────────────────────────────── // 押し目シグナル — ウォッチリスト銘柄の日足からクライアント側で判定 // // ルール(日足ベース・大引けデータで判定): // 前提 急騰検知後、高値が25日線から +8% 以上乖離したら「監視開始」 // 押し目① 日足の安値が 25日線×1.005 以下にタッチ // 押し目② ①の後、安値が 75日線×1.005 以下にタッチ // 終了 終値が 75日線×0.95 を割ったら「調整入り」でシナリオ終了 // // データ源: stock_ohlc(Supabase) → Yahoo Finance フォールバック。 // real-chart.jsx と同じ localStorage キャッシュ(rc-ohlc-v2:)を共有。 // 通知はサイト内のみ: ウォッチタブのバッジ + ウォッチリスト先頭の一覧。 // 既読管理: shodou_dip_seen_v1 (localStorage) // // デモ: URL に ?dipdemo を付けると合成データで表示を確認できる // ──────────────────────────────────────────────────────────── const _DIP_SEEN_KEY = "shodou_dip_seen_v1"; const _DIP_EVENT = "shodou-dip-changed"; const _DIP_WL_KEY = "shodou_watchlist_v1"; const _DIP_CACHE_PREF = "rc-ohlc-v2:"; // real-chart と共有 const _DIP_CACHE_TTL = 15 * 60 * 1000; const _DIP_ARM = 1.08; // 25日線から+8%乖離で監視開始 const _DIP_TOUCH = 1.005; // タッチ判定の遊び +0.5% const _DIP_BREAK = 0.95; // 75日線-5%割れでシナリオ終了 const _DIP_NEAR_PCT = 3.0; // 「あと◯%」表示のしきい値 const _DIP_ALERT_DAYS = 14; // 通知一覧に出す期間 const _DIP_MAX_CODES = 20; // 判定対象の上限 const _DIP_DEMO = /[?]dipdemo/.test(location.search + location.hash); // ---- 小物 ---- const _dipMdFmt = new Intl.DateTimeFormat("ja-JP", { timeZone: "Asia/Tokyo", month: "numeric", day: "numeric" }); const _dipYmdFmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Tokyo", year: "numeric", month: "2-digit", day: "2-digit" }); function _dipMd(t) { try { return _dipMdFmt.format(new Date(t)); } catch (e) { return ""; } } function _dipYmd(t) { try { return _dipYmdFmt.format(new Date(t)); } catch (e) { return ""; } } function _dipCacheGet(key) { try { const raw = localStorage.getItem(_DIP_CACHE_PREF + key); if (!raw) return null; const { t, bars } = JSON.parse(raw); if (Date.now() - t > _DIP_CACHE_TTL) return null; return bars; } catch (e) { return null; } } function _dipCacheSet(key, bars) { try { localStorage.setItem(_DIP_CACHE_PREF + key, JSON.stringify({ t: Date.now(), bars })); } catch (e) {} } function _dipSeenRead() { try { const o = JSON.parse(localStorage.getItem(_DIP_SEEN_KEY) || "{}"); return o && typeof o === "object" ? o : {}; } catch (e) { return {}; } } function _dipSeenWrite(o) { try { localStorage.setItem(_DIP_SEEN_KEY, JSON.stringify(o)); } catch (e) {} } // ---- 日足取得(Supabase → Yahoo)---- async function _dipFetchBarsBatch(codes) { const out = {}; const missing = []; for (const c of codes) { const hit = _dipCacheGet(c + ":D"); if (hit) out[c] = hit; else missing.push(c); } if (!missing.length) return out; // 1) Supabase stock_ohlc をまとめて1リクエスト try { const cfg = window.SITE_CONFIG || {}; if (cfg.SUPABASE_URL && cfg.SUPABASE_ANON_KEY) { const u = `${cfg.SUPABASE_URL}/rest/v1/stock_ohlc` + `?code=in.(${missing.map(encodeURIComponent).join(",")})&interval=eq.D&select=code,bars`; const res = await fetch(u, { headers: { apikey: cfg.SUPABASE_ANON_KEY, Authorization: "Bearer " + cfg.SUPABASE_ANON_KEY }, }); if (res.ok) { for (const row of await res.json()) { const raw = row && row.bars; if (Array.isArray(raw) && raw.length >= 5) { const bars = raw.map(a => ({ t: a[0], o: a[1], h: a[2], l: a[3], c: a[4], v: a[5] || 0 })); out[row.code] = bars; _dipCacheSet(row.code + ":D", bars); } } } } } catch (e) {} // 2) Yahoo フォールバック(残りのみ・最大6銘柄) const still = missing.filter(c => !out[c]).slice(0, 6); for (const code of still) { const url = `https://query1.finance.yahoo.com/v8/finance/chart/${code}.T?range=1y&interval=1d`; const attempts = [url, "https://corsproxy.io/?url=" + encodeURIComponent(url), "https://api.allorigins.win/raw?url=" + encodeURIComponent(url)]; for (const u of attempts) { try { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 12000); const res = await fetch(u, { signal: ctl.signal }); clearTimeout(timer); if (!res.ok) continue; const j = await res.json(); const r = j && j.chart && j.chart.result && j.chart.result[0]; const q = r && r.indicators && r.indicators.quote && r.indicators.quote[0]; if (!r || !q || !r.timestamp) continue; const bars = []; for (let i = 0; i < r.timestamp.length; i++) { const o = q.open[i], h = q.high[i], l = q.low[i], c = q.close[i]; if ([o, h, l, c].some(v => v == null || v !== v)) continue; bars.push({ t: r.timestamp[i] * 1000, o, h, l, c, v: (q.volume && q.volume[i]) || 0 }); } if (bars.length >= 25) { out[code] = bars; _dipCacheSet(code + ":D", bars); break; } } catch (e) {} } } return out; } // ---- 現在株価(stock_prices まとめて1リクエスト)---- async function _dipFetchPrices(codes) { try { const cfg = window.SITE_CONFIG || {}; if (!cfg.SUPABASE_URL || !cfg.SUPABASE_ANON_KEY || !codes.length) return {}; const u = `${cfg.SUPABASE_URL}/rest/v1/stock_prices` + `?stock_code=in.(${codes.map(encodeURIComponent).join(",")})&select=stock_code,price`; const res = await fetch(u, { headers: { apikey: cfg.SUPABASE_ANON_KEY, Authorization: "Bearer " + cfg.SUPABASE_ANON_KEY }, }); if (!res.ok) return {}; const m = {}; for (const r of await res.json()) if (r.price != null) m[String(r.stock_code)] = Number(r.price); return m; } catch (e) { return {}; } } // ---- 判定本体 ---- // bars: [{t,o,h,l,c}] 昇順日足 / detectedAtIso: 急騰検知日時 / currentPrice: 現在値(無ければ最終終値) function computeDipSignal(bars, detectedAtIso, currentPrice) { if (!Array.isArray(bars) || bars.length < 25) return null; const closes = bars.map(b => b.c); const sum = [0]; for (let i = 0; i < closes.length; i++) sum.push(sum[i] + closes[i]); const ma = (i, n) => (i >= n - 1 ? (sum[i + 1] - sum[i + 1 - n]) / n : null); let startIdx = Math.max(0, bars.length - 60); if (detectedAtIso) { const ts = new Date(detectedAtIso).getTime() - 12 * 3600 * 1000; if (ts === ts) { const idx = bars.findIndex(b => b.t >= ts); if (idx >= 0) startIdx = idx; } } let armed = false, armedAt = null; let armedStrong = false, armedStrongAt = null; // 25日線・75日線の両方から+8%乖離(アーカイブ用の厳しめ判定) let stage = 0, dip1At = null, dip2At = null; let ended = false, endedAt = null; for (let i = startIdx; i < bars.length; i++) { const m25 = ma(i, 25), m75 = ma(i, 75); if (m25 == null) continue; // 25日線・75日線の両方を+8%上抜けた瞬間があれば「本物の上昇局面」とみなす(既存の armed とは独立) if (!armedStrong && m75 != null && bars[i].h >= m25 * _DIP_ARM && bars[i].h >= m75 * _DIP_ARM) { armedStrong = true; armedStrongAt = bars[i].t; } if (!armed) { if (bars[i].h >= m25 * _DIP_ARM) { armed = true; armedAt = bars[i].t; } continue; } if (stage < 1 && bars[i].l <= m25 * _DIP_TOUCH) { stage = 1; dip1At = bars[i].t; } if (stage >= 1 && stage < 2 && m75 != null && bars[i].l <= m75 * _DIP_TOUCH) { stage = 2; dip2At = bars[i].t; } if (m75 != null && bars[i].c < m75 * _DIP_BREAK) { ended = true; endedAt = bars[i].t; break; } } const last = bars.length - 1; const ma25 = ma(last, 25), ma75 = ma(last, 75); const cur = currentPrice != null ? Number(currentPrice) : closes[last]; const dist = (m) => (m != null && cur > 0 ? ((cur - m) / m) * 100 : null); return { armed, armedAt, armedStrong, armedStrongAt, stage, dip1At, dip2At, ended, endedAt, ma25: ma25 != null ? Math.round(ma25 * 10) / 10 : null, ma75: ma75 != null ? Math.round(ma75 * 10) / 10 : null, distMa25: dist(ma25), distMa75: dist(ma75), lastBarT: bars[last].t, }; } // ---- シグナル → 通知一覧 ---- function _dipBuildAlerts(items, signals) { const cutoff = Date.now() - _DIP_ALERT_DAYS * 86400 * 1000; const alerts = []; for (const it of items) { const s = signals[it.code]; if (!s) continue; if (s.dip1At && s.dip1At >= cutoff) alerts.push({ key: `${it.code}:d1:${_dipYmd(s.dip1At)}`, code: it.code, name: it.name, stage: 1, t: s.dip1At, item: it }); if (s.dip2At && s.dip2At >= cutoff) alerts.push({ key: `${it.code}:d2:${_dipYmd(s.dip2At)}`, code: it.code, name: it.name, stage: 2, t: s.dip2At, item: it }); } alerts.sort((a, b) => b.t - a.t); return alerts; } // ---- デモデータ(?dipdemo)---- function _dipDemoState() { const d = (n) => Date.now() - n * 86400 * 1000; const items = [ { code: "6920", name: "レーザーテック", market: "東", reason: "半導体検査装置の受注観測で急騰", detected_at: new Date(d(38)).toISOString(), surge_price: 31200, current_price: 34350, added_at: new Date(d(38)).toISOString(), themes: ["半導体"] }, { code: "4935", name: "リベルタ", market: "東", reason: "猛暑対策商品の販売好調観測", detected_at: new Date(d(55)).toISOString(), surge_price: 1180, current_price: 1032, added_at: new Date(d(55)).toISOString(), themes: ["猛暑"] }, { code: "7203", name: "トヨタ自動車", market: "東", reason: "決算上方修正で買い優勢", detected_at: new Date(d(20)).toISOString(), surge_price: 2890, current_price: 3310, added_at: new Date(d(20)).toISOString(), themes: ["決算"] }, ]; const signals = { "6920": { armed: true, stage: 1, dip1At: d(2), dip2At: null, ended: false, ma25: 34120, ma75: 30840, distMa25: 0.7, distMa75: 11.4, lastBarT: d(0) }, "4935": { armed: true, stage: 2, dip1At: d(11), dip2At: d(1), ended: false, ma25: 1098, ma75: 1041, distMa25: -6.0, distMa75: -0.9, lastBarT: d(0) }, "7203": { armed: true, stage: 0, dip1At: null, dip2At: null, ended: false, ma25: 3245, ma75: 3010, distMa25: 2.0, distMa75: 10.0, lastBarT: d(0) }, }; return { status: "ready", signals, alerts: _dipBuildAlerts(items, signals), demoItems: items, updatedAt: Date.now() }; } // ---- エンジン(シングルトン)---- const DipSignals = { demo: _DIP_DEMO, state: _DIP_DEMO ? _dipDemoState() : { status: "idle", signals: {}, alerts: [], updatedAt: null }, _demoSeen: {}, _running: false, _emit() { try { window.dispatchEvent(new CustomEvent(_DIP_EVENT)); } catch (e) {} }, _watchItems() { try { const l = JSON.parse(localStorage.getItem(_DIP_WL_KEY) || "[]"); return Array.isArray(l) ? l : []; } catch (e) { return []; } }, async refresh(force) { if (this.demo) { this._emit(); return; } if (this._running) return; if (!force && this.state.updatedAt && Date.now() - this.state.updatedAt < 5 * 60 * 1000) return; const items = this._watchItems().slice(0, _DIP_MAX_CODES).filter(x => x.code); if (!items.length) { this.state = { status: "ready", signals: {}, alerts: [], updatedAt: Date.now() }; this._emit(); return; } this._running = true; this.state = { ...this.state, status: "loading" }; this._emit(); try { const codes = items.map(x => String(x.code)); const [barsMap, priceMap] = await Promise.all([_dipFetchBarsBatch(codes), _dipFetchPrices(codes)]); const signals = {}; for (const it of items) { const bars = barsMap[it.code]; if (!bars) continue; const sig = computeDipSignal(bars, it.detected_at, priceMap[it.code] ?? it.current_price ?? null); if (sig) signals[it.code] = sig; } this.state = { status: "ready", signals, alerts: _dipBuildAlerts(items, signals), updatedAt: Date.now() }; } catch (e) { this.state = { ...this.state, status: "ready", updatedAt: Date.now() }; } this._running = false; this._emit(); }, isSeen(key) { return this.demo ? !!this._demoSeen[key] : !!_dipSeenRead()[key]; }, unseen() { return this.state.alerts.filter(a => !this.isSeen(a.key)); }, markSeen(keys) { if (!keys || !keys.length) return; if (this.demo) { keys.forEach(k => { this._demoSeen[k] = 1; }); this._emit(); return; } const o = _dipSeenRead(); const now = Date.now(); keys.forEach(k => { o[k] = now; }); // 古い既読キー(60日超)は掃除 for (const k of Object.keys(o)) if (now - o[k] > 60 * 86400 * 1000) delete o[k]; _dipSeenWrite(o); this._emit(); }, }; // ---- React フック ---- function useDipSignals() { const [, force] = React.useReducer(x => x + 1, 0); React.useEffect(() => { const f = () => force(); window.addEventListener(_DIP_EVENT, f); return () => window.removeEventListener(_DIP_EVENT, f); }, []); return DipSignals; } function useDipUnseen() { const dip = useDipSignals(); return dip.state.status === "ready" || dip.demo ? dip.unseen().length : 0; } // ---- UI: タブ用バッジ ---- function DipBadge({ style }) { const n = useDipUnseen(); if (!n) return null; return ( {n > 9 ? "9+" : n} ); } // ---- UI: ウォッチリスト先頭の通知一覧 ---- function DipAlertsSection({ navigate }) { const dip = useDipSignals(); const alerts = dip.state.alerts; // このマウント時点の未読を NEW 表示に固定し、表示と同時に既読化 const [newKeys] = React.useState(() => new Set(dip.unseen().map(a => a.key))); React.useEffect(() => { dip.refresh(); }, []); React.useEffect(() => { const un = dip.unseen().map(a => a.key); if (un.length) { un.forEach(k => newKeys.add(k)); dip.markSeen(un); } }, [dip.state.updatedAt]); if (!alerts.length && dip.state.status !== "loading") return null; return (