@mulsok/traders-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +103 -0
  2. package/bin/cli.js +160 -0
  3. package/bin/postinstall.js +57 -0
  4. package/bin/preuninstall.js +36 -0
  5. package/dist/server/broker/kiwoom/cache.js +86 -0
  6. package/dist/server/broker/kiwoom/cache.js.map +1 -0
  7. package/dist/server/broker/kiwoom/client.js +256 -0
  8. package/dist/server/broker/kiwoom/client.js.map +1 -0
  9. package/dist/server/broker/kiwoom/endpoints/_helpers.js +61 -0
  10. package/dist/server/broker/kiwoom/endpoints/_helpers.js.map +1 -0
  11. package/dist/server/broker/kiwoom/endpoints/account.js +448 -0
  12. package/dist/server/broker/kiwoom/endpoints/account.js.map +1 -0
  13. package/dist/server/broker/kiwoom/endpoints/detail.js +118 -0
  14. package/dist/server/broker/kiwoom/endpoints/detail.js.map +1 -0
  15. package/dist/server/broker/kiwoom/endpoints/investor.js +139 -0
  16. package/dist/server/broker/kiwoom/endpoints/investor.js.map +1 -0
  17. package/dist/server/broker/kiwoom/endpoints/order.js +134 -0
  18. package/dist/server/broker/kiwoom/endpoints/order.js.map +1 -0
  19. package/dist/server/broker/kiwoom/endpoints/quote.js +165 -0
  20. package/dist/server/broker/kiwoom/endpoints/quote.js.map +1 -0
  21. package/dist/server/broker/kiwoom/endpoints/ranking.js +180 -0
  22. package/dist/server/broker/kiwoom/endpoints/ranking.js.map +1 -0
  23. package/dist/server/broker/kiwoom/endpoints/sector.js +135 -0
  24. package/dist/server/broker/kiwoom/endpoints/sector.js.map +1 -0
  25. package/dist/server/broker/kiwoom/endpoints/theme.js +104 -0
  26. package/dist/server/broker/kiwoom/endpoints/theme.js.map +1 -0
  27. package/dist/server/broker/kiwoom/endpoints/universe.js +119 -0
  28. package/dist/server/broker/kiwoom/endpoints/universe.js.map +1 -0
  29. package/dist/server/broker/kiwoom/index.js +59 -0
  30. package/dist/server/broker/kiwoom/index.js.map +1 -0
  31. package/dist/server/broker/kiwoom/order-tracker.js +353 -0
  32. package/dist/server/broker/kiwoom/order-tracker.js.map +1 -0
  33. package/dist/server/broker/kiwoom/price-feed.js +119 -0
  34. package/dist/server/broker/kiwoom/price-feed.js.map +1 -0
  35. package/dist/server/broker/kiwoom/rate-limiter.js +97 -0
  36. package/dist/server/broker/kiwoom/rate-limiter.js.map +1 -0
  37. package/dist/server/broker/kiwoom/types.js +13 -0
  38. package/dist/server/broker/kiwoom/types.js.map +1 -0
  39. package/dist/server/broker/kiwoom/ws/client.js +370 -0
  40. package/dist/server/broker/kiwoom/ws/client.js.map +1 -0
  41. package/dist/server/broker/kiwoom/ws/endpoints/condition.js +146 -0
  42. package/dist/server/broker/kiwoom/ws/endpoints/condition.js.map +1 -0
  43. package/dist/server/broker/kiwoom/ws/realtime-bus.js +42 -0
  44. package/dist/server/broker/kiwoom/ws/realtime-bus.js.map +1 -0
  45. package/dist/server/broker/kiwoom/ws/types.js +19 -0
  46. package/dist/server/broker/kiwoom/ws/types.js.map +1 -0
  47. package/dist/server/broker/news.js +34 -0
  48. package/dist/server/broker/news.js.map +1 -0
  49. package/dist/server/bundle.js +43 -0
  50. package/dist/server/bundle.js.map +1 -0
  51. package/dist/server/calendar/krx-holidays.js +162 -0
  52. package/dist/server/calendar/krx-holidays.js.map +1 -0
  53. package/dist/server/config.js +263 -0
  54. package/dist/server/config.js.map +1 -0
  55. package/dist/server/db/sqlite.js +252 -0
  56. package/dist/server/db/sqlite.js.map +1 -0
  57. package/dist/server/diary/writer.js +266 -0
  58. package/dist/server/diary/writer.js.map +1 -0
  59. package/dist/server/index.js +316 -0
  60. package/dist/server/index.js.map +1 -0
  61. package/dist/server/jobs/universe-sync.js +132 -0
  62. package/dist/server/jobs/universe-sync.js.map +1 -0
  63. package/dist/server/jobs/watchdog.js +87 -0
  64. package/dist/server/jobs/watchdog.js.map +1 -0
  65. package/dist/server/journal/pnl-stats.js +108 -0
  66. package/dist/server/journal/pnl-stats.js.map +1 -0
  67. package/dist/server/journal/telemetry.js +174 -0
  68. package/dist/server/journal/telemetry.js.map +1 -0
  69. package/dist/server/journal/trade-journal.js +239 -0
  70. package/dist/server/journal/trade-journal.js.map +1 -0
  71. package/dist/server/llm/anthropic.js +98 -0
  72. package/dist/server/llm/anthropic.js.map +1 -0
  73. package/dist/server/llm/claude-cli.js +204 -0
  74. package/dist/server/llm/claude-cli.js.map +1 -0
  75. package/dist/server/llm/context-builder.js +229 -0
  76. package/dist/server/llm/context-builder.js.map +1 -0
  77. package/dist/server/llm/gemini.js +86 -0
  78. package/dist/server/llm/gemini.js.map +1 -0
  79. package/dist/server/llm/index.js +36 -0
  80. package/dist/server/llm/index.js.map +1 -0
  81. package/dist/server/llm/openai.js +87 -0
  82. package/dist/server/llm/openai.js.map +1 -0
  83. package/dist/server/personas/executor.js +318 -0
  84. package/dist/server/personas/executor.js.map +1 -0
  85. package/dist/server/personas/loader.js +165 -0
  86. package/dist/server/personas/loader.js.map +1 -0
  87. package/dist/server/personas/persona-agent.js +386 -0
  88. package/dist/server/personas/persona-agent.js.map +1 -0
  89. package/dist/server/personas/persona-state.js +170 -0
  90. package/dist/server/personas/persona-state.js.map +1 -0
  91. package/dist/server/personas/runner.js +162 -0
  92. package/dist/server/personas/runner.js.map +1 -0
  93. package/dist/server/personas/schema.js +123 -0
  94. package/dist/server/personas/schema.js.map +1 -0
  95. package/dist/server/personas/wake-plan.js +313 -0
  96. package/dist/server/personas/wake-plan.js.map +1 -0
  97. package/dist/server/readiness.js +414 -0
  98. package/dist/server/readiness.js.map +1 -0
  99. package/dist/server/routes.js +1216 -0
  100. package/dist/server/routes.js.map +1 -0
  101. package/dist/server/safety.js +153 -0
  102. package/dist/server/safety.js.map +1 -0
  103. package/dist/server/screener/engine.js +856 -0
  104. package/dist/server/screener/engine.js.map +1 -0
  105. package/dist/server/server-sync.js +427 -0
  106. package/dist/server/server-sync.js.map +1 -0
  107. package/dist/server/signing.js +39 -0
  108. package/dist/server/signing.js.map +1 -0
  109. package/dist/server/watchers/condition-watcher.js +519 -0
  110. package/dist/server/watchers/condition-watcher.js.map +1 -0
  111. package/dist/server/watchers/types.js +16 -0
  112. package/dist/server/watchers/types.js.map +1 -0
  113. package/dist/web/assets/index-62SMpbaf.js +79 -0
  114. package/dist/web/assets/index-BPLQR0wt.css +1 -0
  115. package/dist/web/index.html +14 -0
  116. package/package.json +93 -0
  117. package/scripts/com.mulsok.traders.client.plist.template +58 -0
  118. package/scripts/install-daemon.sh +156 -0
  119. package/scripts/uninstall-daemon.sh +62 -0
@@ -0,0 +1,856 @@
1
+ /**
2
+ * 범용 스크리너 엔진 (apps/client)
3
+ *
4
+ * 설계 원칙 (ADR-008):
5
+ * - 페르소나 전략은 프롬프트에 살아 있고, 스크리닝 조건은 **DSL(JSON)** 로 표현
6
+ * - 엔진은 도메인 중립 · 페르소나 비의존
7
+ * - 하드코딩된 페르소나 전용 스크리너(구 neoul-screening.ts) 를 폐기하고 이로 통합
8
+ *
9
+ * DSL:
10
+ * {
11
+ * maOrder: { asc: [20, 60, 120] } // MA20 < MA60 < MA120 (역배열이면 desc)
12
+ * volumeAvgMultiple: { baselineDays: 90, minRatio: 5 }
13
+ * circulatingVolumeRatio: { minPct: 30 }
14
+ * priceRange: { minKrw: 1000, maxKrw: 100000 }
15
+ * marketCapRange: { minKrw: 50_000_000_000 } // 500억 이상 (listCount * price)
16
+ * excludeStates: ["관리종목", "정리매매", "투자주의환기종목"]
17
+ * marketIn: ["kospi", "kosdaq"]
18
+ * }
19
+ *
20
+ * 실행:
21
+ * const hits = await runScreener({ maOrder: { desc: [20, 60, 120] } });
22
+ * → 후보 종목 code[] 반환 · LLM 은 code 들만 받고 상세 분석
23
+ *
24
+ * 데이터 소스:
25
+ * - universe: SQLite universe 테이블 (ka10099 배치 결과)
26
+ * - candles: SQLite daily_candles 테이블 (ka10081 배치 결과)
27
+ *
28
+ * 배치가 선행되어야 함. 없으면 `empty` 결과 + 경고 메시지.
29
+ */
30
+ import { countUniverse, getCandles, listUniverse, } from "../db/sqlite.js";
31
+ /**
32
+ * 「보통주만」 필터 — ETF/ETN/ELW/우선주/리츠/스팩 제외 (선택).
33
+ *
34
+ * 양음양·역배열 등 「세력 의도 + 매물대」 패턴은 보통주에만 의미.
35
+ * ETF/ETN 은 운용사 발행이라 캔들 패턴이 종목 자체 의도와 무관 → 제외 권장.
36
+ */
37
+ const ETF_NAME_PREFIXES = [
38
+ "KODEX", "TIGER", "KBSTAR", "ARIRANG", "ACE", "KOSEF", "HANARO",
39
+ "SOL", "PLUS", "RISE", "KIWOOM", "TIMEFOLIO", "FOCUS", "BNK",
40
+ "마이다스", "신한", "한투", "삼성KODEX", "미래에셋",
41
+ ];
42
+ const ETF_NAME_KEYWORDS = ["ETF", "ETN", "선물인버스", "레버리지", "ELS", "ELW", "워런트"];
43
+ const REIT_KEYWORDS = ["리츠", "REITs", "REIT"];
44
+ const SPAC_KEYWORDS = ["스팩", "SPAC"];
45
+ function isPreferredCode(code) {
46
+ // 한국 6자리 보통주 = 끝자리 0. 우선주 = 5/6/7/8/9 (1우/2우B/3우B 등).
47
+ if (!/^\d{6}$/.test(code))
48
+ return false;
49
+ const last = code.charCodeAt(5) - 48;
50
+ return last >= 5 && last <= 9;
51
+ }
52
+ function isAlphaInCode(code) {
53
+ // ETF/ETN 액티브 등은 종목 코드에 알파벳 (예: 0148J0)
54
+ return /[A-Za-z]/.test(code);
55
+ }
56
+ function isNonCommon(name, code) {
57
+ if (isPreferredCode(code))
58
+ return { excluded: true, reason: "preferred" };
59
+ if (isAlphaInCode(code))
60
+ return { excluded: true, reason: "code_alpha" };
61
+ const upper = name.toUpperCase();
62
+ for (const p of ETF_NAME_PREFIXES) {
63
+ if (name.startsWith(p) || upper.startsWith(p))
64
+ return { excluded: true, reason: `etf_prefix:${p}` };
65
+ }
66
+ for (const kw of ETF_NAME_KEYWORDS) {
67
+ if (name.includes(kw) || upper.includes(kw))
68
+ return { excluded: true, reason: `keyword:${kw}` };
69
+ }
70
+ for (const kw of REIT_KEYWORDS) {
71
+ if (name.includes(kw) || upper.includes(kw))
72
+ return { excluded: true, reason: `reit:${kw}` };
73
+ }
74
+ for (const kw of SPAC_KEYWORDS) {
75
+ if (name.includes(kw) || upper.includes(kw))
76
+ return { excluded: true, reason: `spac:${kw}` };
77
+ }
78
+ // 종목명 끝이 "우" 또는 "우B" 같은 우선주 패턴 (코드로 안 잡힌 케이스 보강)
79
+ if (/우[A-Z]?$/.test(name) || /우\d$/.test(name)) {
80
+ return { excluded: true, reason: "name_preferred_suffix" };
81
+ }
82
+ return { excluded: false };
83
+ }
84
+ export async function runScreener(filter) {
85
+ const start = Date.now();
86
+ const universeSize = countUniverse();
87
+ const diagnostics = {
88
+ universeAfterStaticFilter: 0,
89
+ rejectedBy: {
90
+ priceRange: 0,
91
+ marketCapRange: 0,
92
+ excludeStates: 0,
93
+ nonCommon: 0,
94
+ maOrder: 0,
95
+ volumeAvgMultiple: 0,
96
+ circulatingVolumeRatio: 0,
97
+ candlePattern: 0,
98
+ noCandles: 0,
99
+ },
100
+ patternReject: {},
101
+ samples: [],
102
+ };
103
+ if (universeSize === 0) {
104
+ return {
105
+ filter,
106
+ candidates: [],
107
+ universeSize: 0,
108
+ evaluated: 0,
109
+ skippedNoCandles: 0,
110
+ elapsedMs: Date.now() - start,
111
+ diagnostics,
112
+ dataAsOf: undefined,
113
+ };
114
+ }
115
+ // 데이터 기준일 추적 — 평가된 candle 중 가장 최신 date (D-0)
116
+ let latestCandleDate = "";
117
+ const markets = filter.marketIn ?? ["kospi", "kosdaq"];
118
+ const rows = [];
119
+ for (const m of markets) {
120
+ rows.push(...listUniverse({ market: m }));
121
+ }
122
+ const hits = [];
123
+ let evaluated = 0;
124
+ let skippedNoCandles = 0;
125
+ const addSample = (row, stoppedAt, detail) => {
126
+ if (diagnostics.samples.length < 5) {
127
+ diagnostics.samples.push({ code: row.code, name: row.name, stoppedAt, detail });
128
+ }
129
+ };
130
+ for (const row of rows) {
131
+ // 1차 static 필터 (DB 만으로 판단 가능)
132
+ if (filter.excludeStates) {
133
+ if (filter.excludeStates.some((bad) => (row.state ?? "").includes(bad))) {
134
+ diagnostics.rejectedBy.excludeStates++;
135
+ continue;
136
+ }
137
+ }
138
+ // 보통주만 (ETF·ETN·ELW·우선주·리츠·스팩 제외)
139
+ if (filter.excludeNonCommon) {
140
+ const nc = isNonCommon(row.name, row.code);
141
+ if (nc.excluded) {
142
+ diagnostics.rejectedBy.nonCommon++;
143
+ addSample(row, "non_common", nc.reason ?? "");
144
+ continue;
145
+ }
146
+ }
147
+ if (filter.priceRange) {
148
+ if (filter.priceRange.minKrw !== undefined && row.last_price_krw < filter.priceRange.minKrw) {
149
+ diagnostics.rejectedBy.priceRange++;
150
+ continue;
151
+ }
152
+ if (filter.priceRange.maxKrw !== undefined && row.last_price_krw > filter.priceRange.maxKrw) {
153
+ diagnostics.rejectedBy.priceRange++;
154
+ continue;
155
+ }
156
+ }
157
+ if (filter.marketCapRange) {
158
+ const cap = row.last_price_krw * row.list_count;
159
+ if (filter.marketCapRange.minKrw !== undefined && cap < filter.marketCapRange.minKrw) {
160
+ diagnostics.rejectedBy.marketCapRange++;
161
+ continue;
162
+ }
163
+ if (filter.marketCapRange.maxKrw !== undefined && cap > filter.marketCapRange.maxKrw) {
164
+ diagnostics.rejectedBy.marketCapRange++;
165
+ continue;
166
+ }
167
+ }
168
+ diagnostics.universeAfterStaticFilter++;
169
+ // 2차 candles 기반 필터
170
+ const needsCandles = !!filter.maOrder ||
171
+ !!filter.volumeAvgMultiple ||
172
+ !!filter.circulatingVolumeRatio ||
173
+ (!!filter.candlePatterns && filter.candlePatterns.length > 0);
174
+ if (!needsCandles) {
175
+ hits.push(buildHit(row, [], [], {}));
176
+ evaluated++;
177
+ continue;
178
+ }
179
+ // 패턴별 최소 요구 일수 (필수 평가에 필요한 만큼만 · 가산점은 candles 충분할 때만 적용)
180
+ const patternMinDays = (filter.candlePatterns ?? []).reduce((m, p) => {
181
+ if (p.kind === "yinYangBull") {
182
+ const baselineDays = p.params?.d1VolumeBaselineDays ?? 60;
183
+ // longMaBonus 는 추가 정보 (없어도 패턴 평가 가능) · 필수는 baseline 만
184
+ return Math.max(m, Math.max(baselineDays + 3, 22));
185
+ }
186
+ if (p.kind === "yinYangUpperShadow")
187
+ return Math.max(m, 12);
188
+ if (p.kind === "yinYangSideways") {
189
+ const range = p.params?.sidewaysDaysRange ?? [3, 10];
190
+ return Math.max(m, range[1] + 5);
191
+ }
192
+ if (p.kind === "morningStar")
193
+ return Math.max(m, 3);
194
+ if (p.kind === "engulfingBull")
195
+ return Math.max(m, 2);
196
+ if (p.kind === "hammer")
197
+ return Math.max(m, 1);
198
+ if (p.kind === "maRebound")
199
+ return Math.max(m, p.params.ma);
200
+ return m;
201
+ }, 0);
202
+ const maxPeriod = Math.max(...(filter.maOrder?.asc ?? []), ...(filter.maOrder?.desc ?? []), filter.volumeAvgMultiple?.baselineDays ?? 0, patternMinDays, 5);
203
+ const candles = getCandles(row.code, maxPeriod + 2);
204
+ if (candles.length < maxPeriod) {
205
+ skippedNoCandles++;
206
+ diagnostics.rejectedBy.noCandles++;
207
+ continue;
208
+ }
209
+ evaluated++;
210
+ // 가장 최신 candle date 추적 (DESC 정렬이라 [0])
211
+ if (candles[0]?.date && candles[0].date > latestCandleDate) {
212
+ latestCandleDate = candles[0].date;
213
+ }
214
+ const reasons = [];
215
+ const ma = {};
216
+ // MA order
217
+ if (filter.maOrder) {
218
+ const periods = filter.maOrder.asc ?? filter.maOrder.desc;
219
+ const isAsc = !!filter.maOrder.asc;
220
+ const values = periods.map((p) => {
221
+ const m = averageClose(candles, p);
222
+ ma[`ma${p}`] = m;
223
+ return m;
224
+ });
225
+ const ordered = isAsc
226
+ ? values.every((v, i) => i === 0 || v >= values[i - 1])
227
+ : values.every((v, i) => i === 0 || v <= values[i - 1]);
228
+ if (!ordered) {
229
+ diagnostics.rejectedBy.maOrder++;
230
+ addSample(row, "maOrder", `values=[${values.map((v) => Math.round(v)).join(",")}]`);
231
+ continue;
232
+ }
233
+ reasons.push(`MA${isAsc ? "정" : "역"}배열 ${periods.map((p, i) => `${p}일=${Math.round(values[i])}`).join(" → ")}`);
234
+ }
235
+ // Volume average multiple
236
+ if (filter.volumeAvgMultiple) {
237
+ const baselineDays = filter.volumeAvgMultiple.baselineDays;
238
+ if (candles.length < baselineDays + 1) {
239
+ skippedNoCandles++;
240
+ diagnostics.rejectedBy.noCandles++;
241
+ continue;
242
+ }
243
+ const d0 = candles[0];
244
+ const baseline = candles.slice(1, baselineDays + 1);
245
+ const avgVol = baseline.reduce((s, c) => s + c.volume, 0) / baseline.length;
246
+ const ratio = avgVol > 0 ? d0.volume / avgVol : 0;
247
+ if (ratio < filter.volumeAvgMultiple.minRatio) {
248
+ diagnostics.rejectedBy.volumeAvgMultiple++;
249
+ addSample(row, "volumeAvgMultiple", `ratio=${ratio.toFixed(2)}`);
250
+ continue;
251
+ }
252
+ reasons.push(`거래량 ${baselineDays}일 평균 대비 ${ratio.toFixed(1)}배`);
253
+ }
254
+ // Circulating volume ratio
255
+ if (filter.circulatingVolumeRatio) {
256
+ if (row.list_count <= 0) {
257
+ diagnostics.rejectedBy.circulatingVolumeRatio++;
258
+ continue;
259
+ }
260
+ const d0 = candles[0];
261
+ const pct = (d0.volume / row.list_count) * 100;
262
+ if (pct < filter.circulatingVolumeRatio.minPct) {
263
+ diagnostics.rejectedBy.circulatingVolumeRatio++;
264
+ addSample(row, "circulatingVolumeRatio", `${pct.toFixed(2)}%`);
265
+ continue;
266
+ }
267
+ reasons.push(`유통주식수 대비 거래량 ${pct.toFixed(1)}%`);
268
+ }
269
+ // 캔들 패턴 프리셋 (mode 에 따라 AND 또는 ANY)
270
+ // 통과 = score 1 / 실패 = 0 · 가산점 평가는 LLM 영역
271
+ const matchedPatterns = [];
272
+ const humanSummaries = [];
273
+ const aggregatedMetrics = {};
274
+ if (filter.candlePatterns && filter.candlePatterns.length > 0) {
275
+ const mode = filter.candlePatternsMode ?? "and";
276
+ const matches = filter.candlePatterns.map((spec) => ({
277
+ kind: spec.kind,
278
+ result: matchCandlePattern(spec, candles, row),
279
+ }));
280
+ // 진단 집계 (탈락 이유)
281
+ for (const m of matches) {
282
+ if (!m.result.passed) {
283
+ const failKey = m.result.failReason ?? "unknown";
284
+ diagnostics.patternReject[m.kind] = diagnostics.patternReject[m.kind] ?? {};
285
+ diagnostics.patternReject[m.kind][failKey] =
286
+ (diagnostics.patternReject[m.kind][failKey] ?? 0) + 1;
287
+ }
288
+ }
289
+ if (mode === "and") {
290
+ const allPassed = matches.every((m) => m.result.passed);
291
+ if (!allPassed) {
292
+ diagnostics.rejectedBy.candlePattern++;
293
+ const firstFail = matches.find((m) => !m.result.passed);
294
+ if (firstFail)
295
+ addSample(row, `candlePattern:${firstFail.kind}`, firstFail.result.failReason ?? "unknown");
296
+ continue;
297
+ }
298
+ for (const m of matches) {
299
+ matchedPatterns.push(m.kind);
300
+ reasons.push(`【${m.kind}】 ${m.result.reasons.join(" / ")}`);
301
+ if (m.result.humanSummary)
302
+ humanSummaries.push(m.result.humanSummary);
303
+ Object.assign(aggregatedMetrics, m.result.metrics);
304
+ }
305
+ }
306
+ else {
307
+ const passedMatches = matches.filter((m) => m.result.passed);
308
+ if (passedMatches.length === 0) {
309
+ diagnostics.rejectedBy.candlePattern++;
310
+ if (matches[0])
311
+ addSample(row, `candlePattern:${matches[0].kind}`, matches[0].result.failReason ?? "unknown");
312
+ continue;
313
+ }
314
+ for (const m of passedMatches) {
315
+ matchedPatterns.push(m.kind);
316
+ reasons.push(`【${m.kind}】 ${m.result.reasons.join(" / ")}`);
317
+ if (m.result.humanSummary)
318
+ humanSummaries.push(m.result.humanSummary);
319
+ Object.assign(aggregatedMetrics, m.result.metrics);
320
+ }
321
+ }
322
+ }
323
+ hits.push(buildHit(row, reasons, humanSummaries, ma, candles, matchedPatterns, aggregatedMetrics));
324
+ }
325
+ // 정렬
326
+ const sort = filter.sort ?? { by: "score", order: "desc" };
327
+ const order = sort.order ?? "desc";
328
+ hits.sort((a, b) => {
329
+ const av = sortValue(a, sort.by);
330
+ const bv = sortValue(b, sort.by);
331
+ return order === "asc" ? av - bv : bv - av;
332
+ });
333
+ const limited = filter.limit ? hits.slice(0, filter.limit) : hits;
334
+ return {
335
+ filter,
336
+ candidates: limited,
337
+ universeSize,
338
+ evaluated,
339
+ skippedNoCandles,
340
+ elapsedMs: Date.now() - start,
341
+ diagnostics,
342
+ dataAsOf: latestCandleDate || undefined,
343
+ };
344
+ }
345
+ function sortValue(h, by) {
346
+ switch (by) {
347
+ case "score": return h.score;
348
+ case "changePct": return h.todayChangePct;
349
+ case "volume": return h.todayVolume;
350
+ case "marketCap": return h.lastPriceKrw * h.listCount;
351
+ }
352
+ }
353
+ function matchCandlePattern(spec, candles, row) {
354
+ switch (spec.kind) {
355
+ case "yinYangBull": return matchYinYangBull(spec.params ?? {}, candles);
356
+ case "yinYangUpperShadow": return matchYinYangUpperShadow(spec.params ?? {}, candles);
357
+ case "yinYangSideways": return matchYinYangSideways(spec.params ?? {}, candles);
358
+ case "engulfingBull": return matchEngulfingBull(spec.params ?? {}, candles);
359
+ case "morningStar": return matchMorningStar(spec.params ?? {}, candles);
360
+ case "hammer": return matchHammer(spec.params ?? {}, candles);
361
+ case "maRebound": return matchMaRebound(spec.params, candles, row);
362
+ }
363
+ }
364
+ /**
365
+ * 양음양 패턴 1 · 기본형 (5일선 눌림)
366
+ *
367
+ * D-1 장대양봉 + 평소 평균 대비 대량거래 → D-0 음봉 + 거래량 감소 + 5일선 미이탈.
368
+ * taeeun-life v2 원본 충실 · 거래량 비교는 N일 평균 대비 (단순 D-2 1일 대비 X).
369
+ */
370
+ export function matchYinYangBull(p, candles) {
371
+ // 기본값
372
+ const d1MinChangePct = p.d1MinChangePct ?? 5;
373
+ const d1MaxChangePct = p.d1MaxChangePct ?? 20;
374
+ const baselineDays = p.d1VolumeBaselineDays ?? 60;
375
+ const d1MinVolumeRatio = p.d1MinVolumeRatio ?? 1.5;
376
+ const d1ExceptionalVolumeRatio = p.d1ExceptionalVolumeRatio ?? 3;
377
+ const d0MaxVolumeRatio = p.d0MaxVolumeRatio ?? 0.8;
378
+ const d0IdealVolumeRatio = p.d0IdealVolumeRatio ?? 0.6;
379
+ const d0MustBeBearish = p.d0MustBeBearish ?? true;
380
+ const d0BodyIdeal = p.d0BodyRatioIdeal ?? [0.4, 0.6];
381
+ const d0BodyAcceptable = p.d0BodyRatioAcceptable ?? [0.15, 0.7];
382
+ const d0BodyMarginalScore = p.d0BodyRatioMarginalScore ?? 1;
383
+ const mustHoldMa = p.mustHoldMa ?? 5;
384
+ const idealClusterPct = p.maClusterIdealPct ?? 5;
385
+ const acceptClusterPct = p.maClusterAcceptablePct ?? 7;
386
+ const clusterPeriods = p.maClusterPeriods ?? [5, 10, 20];
387
+ const maOrderBonus = p.maOrderBonus ?? true;
388
+ const longMaBreakoutBonus = p.longMaBreakoutBonus ?? true;
389
+ const d0HighVolumeExclude = p.d0HighVolumeExclude ?? true;
390
+ const tradingValueBonus = p.tradingValueBonus ?? true;
391
+ const tvTier1 = p.tradingValueTier1Krw ?? 10_000_000_000; // 100억
392
+ const tvTier2 = p.tradingValueTier2Krw ?? 100_000_000_000; // 1000억
393
+ // 최소 candles 수 (baseline + D-2 + D-1 + D-0 + 안전)
394
+ const minCandles = Math.max(baselineDays + 3, 22);
395
+ if (candles.length < minCandles)
396
+ return fail(`candles<${minCandles}`);
397
+ const d0 = candles[0];
398
+ const d1 = candles[1];
399
+ const d2 = candles[2];
400
+ /* ─── D-1 장대양봉 + 평균 대비 대량거래 ─── */
401
+ if (d2.close_krw <= 0)
402
+ return fail("d2_closed_zero");
403
+ const d1ChangePct = ((d1.close_krw - d2.close_krw) / d2.close_krw) * 100;
404
+ if (d1.close_krw <= d1.open_krw)
405
+ return fail("d1_not_bullish");
406
+ if (d1ChangePct < d1MinChangePct)
407
+ return fail(`d1_change_below_${d1MinChangePct}`);
408
+ if (d1ChangePct > d1MaxChangePct)
409
+ return fail(`d1_change_above_${d1MaxChangePct}`);
410
+ // 거래량: N일 평균 대비 (D-1 자체 제외 · D-2 부터 N일)
411
+ const baselineSlice = candles.slice(2, 2 + baselineDays);
412
+ const avgVolume = baselineSlice.reduce((s, c) => s + c.volume, 0) / baselineSlice.length;
413
+ const d1VolumeMultipleAvg = avgVolume > 0 ? d1.volume / avgVolume : 0;
414
+ if (d1VolumeMultipleAvg < d1MinVolumeRatio) {
415
+ return fail(`d1_avg_volume_ratio_below_${d1MinVolumeRatio}`);
416
+ }
417
+ /* ─── D-0 음봉 + 거래량 감소 ─── */
418
+ if (d0MustBeBearish && d0.close_krw >= d0.open_krw)
419
+ return fail("d0_not_bearish");
420
+ const d0VolumeRatio = d1.volume > 0 ? d0.volume / d1.volume : 0;
421
+ if (d0VolumeRatio > d0MaxVolumeRatio)
422
+ return fail(`d0_volume_ratio_above_${d0MaxVolumeRatio}`);
423
+ if (d0HighVolumeExclude && d0.volume >= d1.volume)
424
+ return fail("d0_high_volume_excluded");
425
+ /* ─── 음봉 몸통 / 양봉 몸통 비율 ─── */
426
+ const d1Body = Math.max(0, d1.close_krw - d1.open_krw);
427
+ const d0Body = Math.max(0, d0.open_krw - d0.close_krw); // 음봉이므로 open - close
428
+ const d0BodyRatio = d1Body > 0 ? d0Body / d1Body : 0;
429
+ if (d0BodyRatio < d0BodyAcceptable[0])
430
+ return fail(`d0_body_too_narrow_${d0BodyRatio.toFixed(2)}`);
431
+ if (d0BodyRatio > d0BodyAcceptable[1])
432
+ return fail(`d0_body_too_wide_${d0BodyRatio.toFixed(2)}`);
433
+ /* ─── 5일선 미이탈 (hard) ─── */
434
+ const ma5 = averageClose(candles, mustHoldMa);
435
+ if (d0.close_krw <= ma5)
436
+ return fail("below_ma5");
437
+ /* ─── 이평선 군집 (5일선 hard cutoff) ─── */
438
+ // taeeun-life v2 원본: "단기 이평선 (5일선 등) 이 -5%권" — 핵심은 5일선
439
+ // → MA5 거리 hard cutoff (acceptable * 1.5 = 약 10%) · 군집 score 는 5/10/20 평균
440
+ // 5일선 -10% 이격 = 분할매수 기준점 활용 가능 한계
441
+ let clusterIdeal = false;
442
+ let clusterAcceptable = false;
443
+ let clusterAvgDistancePct = 0;
444
+ if (clusterPeriods.length >= 2) {
445
+ const values = clusterPeriods.map((per) => averageClose(candles, per)).filter((v) => v > 0);
446
+ if (values.length === clusterPeriods.length) {
447
+ const cur = d0.close_krw;
448
+ // hard: 5일선과의 거리만 (가장 가까워야 할 단기선)
449
+ const ma5Distance = Math.abs(cur - ma5) / cur * 100;
450
+ const hardCutoff = acceptClusterPct * 1.5; // 7 * 1.5 = 10.5% (단기선 한계)
451
+ if (ma5Distance > hardCutoff) {
452
+ return fail(`ma5_distance_above_${hardCutoff.toFixed(1)}pct (${ma5Distance.toFixed(1)}%)`);
453
+ }
454
+ // 군집 평가 (전체 평균)
455
+ clusterAvgDistancePct = values.reduce((s, v) => s + Math.abs(cur - v) / cur * 100, 0) / values.length;
456
+ clusterIdeal = clusterAvgDistancePct <= idealClusterPct;
457
+ clusterAcceptable = clusterAvgDistancePct <= acceptClusterPct;
458
+ }
459
+ }
460
+ /* ─── 정배열 단계 차등 (MA5 > MA10 > MA20 > MA60 > MA120) ─── */
461
+ let maOrderTiers = 0; // 0=비정배열, 2=3단(5/10/20), 3=4단(+60), 4=5단(+120 완전체)
462
+ if (maOrderBonus) {
463
+ const ma10 = averageClose(candles, 10);
464
+ const ma20 = averageClose(candles, 20);
465
+ if (ma5 > 0 && ma10 > 0 && ma20 > 0 && ma5 > ma10 && ma10 > ma20) {
466
+ maOrderTiers = 2; // 3단 정배열 +2
467
+ if (candles.length >= 60) {
468
+ const ma60 = averageClose(candles, 60);
469
+ if (ma60 > 0 && ma20 > ma60) {
470
+ maOrderTiers = 3; // 4단 +3
471
+ if (candles.length >= 120) {
472
+ const ma120 = averageClose(candles, 120);
473
+ if (ma120 > 0 && ma60 > ma120) {
474
+ maOrderTiers = 4; // 5단 완전체 +4
475
+ }
476
+ }
477
+ }
478
+ }
479
+ }
480
+ }
481
+ /* ─── 역배열 → 60·120일선 첫 돌파 가산점 ─── */
482
+ //
483
+ // "D-1 양봉이 장기 MA 첫 돌파" 의 정확 정의:
484
+ // D-1 close ≥ D-1 시점 MA (어제 종가가 어제 MA 위)
485
+ // D-2 close < D-2 시점 MA (그 전날까지는 MA 아래)
486
+ //
487
+ // D-1 시점 MA60 = candles[1..60] 평균 = averageClose(candles.slice(1), 60)
488
+ // D-2 시점 MA60 = candles[2..61] 평균 = averageClose(candles.slice(2), 60)
489
+ let breakthroughLongMa = null;
490
+ if (longMaBreakoutBonus) {
491
+ if (candles.length >= 62) {
492
+ const d1Ma60 = averageClose(candles.slice(1), 60);
493
+ const d2Ma60 = averageClose(candles.slice(2), 60);
494
+ if (d1Ma60 > 0 && d2Ma60 > 0 && d1.close_krw >= d1Ma60 && d2.close_krw < d2Ma60) {
495
+ breakthroughLongMa = 60;
496
+ }
497
+ }
498
+ if (candles.length >= 122) {
499
+ const d1Ma120 = averageClose(candles.slice(1), 120);
500
+ const d2Ma120 = averageClose(candles.slice(2), 120);
501
+ // 120 이 더 강한 신호 · 60 위에 덮어쓰기
502
+ if (d1Ma120 > 0 && d2Ma120 > 0 && d1.close_krw >= d1Ma120 && d2.close_krw < d2Ma120) {
503
+ breakthroughLongMa = 120;
504
+ }
505
+ }
506
+ }
507
+ /*
508
+ * 통과 표시 (score = 1) + 필수 조건 사람용 요약.
509
+ * 가산점 평가 (정배열·거래량 폭발·이상적 음봉·돌파·거래대금 등) 는 LLM 영역.
510
+ * raw 측정값은 metrics 에 담아 LLM 에 전달.
511
+ */
512
+ void d1ExceptionalVolumeRatio;
513
+ void d0IdealVolumeRatio;
514
+ void d0BodyIdeal;
515
+ void d0BodyMarginalScore;
516
+ void idealClusterPct;
517
+ void acceptClusterPct;
518
+ void clusterIdeal;
519
+ void clusterAcceptable;
520
+ void tradingValueBonus;
521
+ void tvTier1;
522
+ void tvTier2;
523
+ const reasons = [
524
+ `양음양 [D-1 +${d1ChangePct.toFixed(1)}% · 평균 ${baselineDays}일 거래량 ${d1VolumeMultipleAvg.toFixed(1)}배]`,
525
+ `[D-0 음봉 · D-1 거래량의 ${(d0VolumeRatio * 100).toFixed(0)}% · 몸통 비율 ${(d0BodyRatio * 100).toFixed(0)}%]`,
526
+ `MA${mustHoldMa} 위`,
527
+ ];
528
+ let maAlignmentLabel = "mixed";
529
+ if (maOrderTiers === 4)
530
+ maAlignmentLabel = "asc_5_10_20_60_120";
531
+ else if (maOrderTiers === 3)
532
+ maAlignmentLabel = "asc_5_10_20_60";
533
+ else if (maOrderTiers === 2)
534
+ maAlignmentLabel = "asc_5_10_20";
535
+ // 트레이더 톤 자연어 풀이
536
+ const volMultLabel = d1VolumeMultipleAvg >= 5
537
+ ? `평소의 ${d1VolumeMultipleAvg.toFixed(1)}배가 폭발적으로 터지면서`
538
+ : d1VolumeMultipleAvg >= 2
539
+ ? `평소보다 ${d1VolumeMultipleAvg.toFixed(1)}배 많은 거래량으로`
540
+ : `거래량이 늘어나며`;
541
+ const d0VolPct = Math.round(d0VolumeRatio * 100);
542
+ const d0VolLabel = d0VolPct <= 30
543
+ ? `오늘 거래량은 어제의 ${d0VolPct}% 밖에 안 돼요. 매도 압박이 거의 없다는 뜻이에요`
544
+ : `오늘 거래량은 어제의 ${d0VolPct}% 수준으로 줄어, 잠시 숨고르기 중이에요`;
545
+ const trendLabel = maOrderTiers >= 3
546
+ ? "단기·중기 이평선이 모두 정배열로 추세가 살아있어요"
547
+ : maOrderTiers === 2
548
+ ? "5·10·20일선 정배열로 단기 추세는 살아있어요"
549
+ : `${mustHoldMa}일 이동평균선 위에서 잘 버티고 있어요`;
550
+ const humanSummary = `어제 ${volMultLabel} ${d1ChangePct.toFixed(1)}% 강하게 올랐어요. ${d0VolLabel}. ${trendLabel}.`;
551
+ const d1TradingValue = d1.close_krw * d1.volume;
552
+ const metrics = {
553
+ d1ChangePct: Number(d1ChangePct.toFixed(2)),
554
+ d1VolumeMultipleAvg: Number(d1VolumeMultipleAvg.toFixed(2)),
555
+ d1VolumeBaselineDays: baselineDays,
556
+ d0VolumeRatio: Number(d0VolumeRatio.toFixed(3)),
557
+ d0BodyRatioVsD1: Number(d0BodyRatio.toFixed(3)),
558
+ d1TradingValueKrw: Math.round(d1TradingValue),
559
+ maAlignment: maAlignmentLabel,
560
+ maClusterDistancePct: Number(clusterAvgDistancePct.toFixed(2)),
561
+ longMaBreakthrough: breakthroughLongMa,
562
+ };
563
+ return { passed: true, score: 1, reasons, humanSummary, metrics };
564
+ }
565
+ /**
566
+ * 양음양 패턴 2 · 윗꼬리형 (당일 회전)
567
+ *
568
+ * D-1 윗꼬리 긴 대량거래 양봉 + 마감이 단기 MA 위 → D-0 거래량 ≤ 50% + 시초 음수.
569
+ * 분봉 데이터 없이 일봉만으로 추정 가능한 부분만 체크. 정밀 검증은 LLM 이 종합.
570
+ */
571
+ export function matchYinYangUpperShadow(p, candles) {
572
+ if (candles.length < 12)
573
+ return fail("candles<12");
574
+ const d0 = candles[0];
575
+ const d1 = candles[1];
576
+ const d2 = candles[2];
577
+ const minUpperShadowRatio = p.d1MinUpperShadowRatio ?? 1.0;
578
+ const mustCloseAboveMa = p.d1MustCloseAboveMa ?? [5, 10];
579
+ const d0MaxVolumeRatio = p.d0MaxVolumeRatio ?? 0.5;
580
+ const d0OpenBelowD1Close = p.d0OpenBelowD1Close ?? true;
581
+ const d1MaxChangePct = p.d1MaxChangePct ?? 22;
582
+ const mustHoldMa = p.mustHoldMa ?? 5;
583
+ /* D-1 양봉 + 윗꼬리 길어야 함 */
584
+ if (d1.close_krw <= d1.open_krw)
585
+ return fail("d1_not_bullish");
586
+ const d1Body = d1.close_krw - d1.open_krw;
587
+ if (d1Body <= 0)
588
+ return fail("d1_no_body");
589
+ /* D-1 등락률 max (20% 초과 급등 제외 · v2 원본) */
590
+ if (d2 && d2.close_krw > 0) {
591
+ const d1ChangePct = ((d1.close_krw - d2.close_krw) / d2.close_krw) * 100;
592
+ if (d1ChangePct > d1MaxChangePct)
593
+ return fail(`d1_change_above_${d1MaxChangePct}`);
594
+ }
595
+ const d1UpperShadow = d1.high_krw - d1.close_krw;
596
+ const upperShadowRatio = d1UpperShadow / d1Body;
597
+ if (upperShadowRatio < minUpperShadowRatio)
598
+ return fail(`upper_shadow_ratio_below_${minUpperShadowRatio}`);
599
+ /* D-0 close > MA5 (5일선 미이탈 hard · 너울 §필수) */
600
+ if (candles.length >= mustHoldMa) {
601
+ const ma = averageClose(candles, mustHoldMa);
602
+ if (ma > 0 && d0.close_krw <= ma)
603
+ return fail(`upper_shadow_d0_below_ma${mustHoldMa}`);
604
+ }
605
+ /*
606
+ * D-1 마감이 단기 이평선 위.
607
+ * taeeun-life v2 원본: "5일선, 10일선 위에서 끝나야만 다음 날 공략 가능"
608
+ * 해석:
609
+ * - 5일선 위는 hard (필수)
610
+ * - 10일선 위까지 있으면 가산점 (둘 다 위 = 더 이상적)
611
+ */
612
+ const periodPriority = [...mustCloseAboveMa].sort((a, b) => a - b); // [5, 10]
613
+ const baseMaPeriod = periodPriority[0]; // 보통 5
614
+ const baseMa = candles.length >= baseMaPeriod + 1
615
+ ? averageClose(candles.slice(1), baseMaPeriod) : 0;
616
+ if (baseMa <= 0 || d1.close_krw <= baseMa) {
617
+ return fail(`d1_close_not_above_ma${baseMaPeriod}`);
618
+ }
619
+ let aboveAllShortMas = true;
620
+ const aboveMaList = [baseMaPeriod];
621
+ for (const period of periodPriority.slice(1)) {
622
+ if (candles.length < period + 1) {
623
+ aboveAllShortMas = false;
624
+ continue;
625
+ }
626
+ const ma = averageClose(candles.slice(1), period);
627
+ if (ma > 0 && d1.close_krw > ma)
628
+ aboveMaList.push(period);
629
+ else
630
+ aboveAllShortMas = false;
631
+ }
632
+ /* D-0 거래량 ≤ 50% + (옵션) 시초 음수 */
633
+ const d0VolumeRatio = d1.volume > 0 ? d0.volume / d1.volume : 0;
634
+ if (d0VolumeRatio > d0MaxVolumeRatio)
635
+ return fail(`d0_volume_ratio_above_${d0MaxVolumeRatio}`);
636
+ if (d0OpenBelowD1Close && d0.open_krw >= d1.close_krw)
637
+ return fail("d0_open_not_below_d1_close");
638
+ /* D-1 거래량은 그래도 평소보다 많아야 (의미 있는 윗꼬리) · D-2 대비 1.5x 가벼운 체크 */
639
+ const d1vsd2 = d2.volume > 0 ? d1.volume / d2.volume : 0;
640
+ if (d1vsd2 < 1.2)
641
+ return fail(`d1_volume_too_normal_${d1vsd2.toFixed(2)}`);
642
+ void aboveAllShortMas;
643
+ const reasons = [
644
+ `윗꼬리형 [D-1 양봉 + 윗꼬리/몸통 ${upperShadowRatio.toFixed(1)}배]`,
645
+ `MA[${aboveMaList.join("·")}] 위 마감`,
646
+ `[D-0 거래량 ${(d0VolumeRatio * 100).toFixed(0)}% · 시초 음수]`,
647
+ ];
648
+ const d0VolPct = Math.round(d0VolumeRatio * 100);
649
+ const maListLabel = aboveMaList.length > 0 ? aboveMaList.map((p) => `${p}일선`).join("·") : "단기선";
650
+ const humanSummary = `어제 큰 매물대 (윗꼬리 ${upperShadowRatio.toFixed(1)}배) 를 만났지만 ${maListLabel} 위에서 마감했어요. 오늘은 거래량이 어제의 ${d0VolPct}% 로 줄고 시초가는 음수 — 매도세가 빠지고 있다는 신호예요.`;
651
+ const metrics = {
652
+ upperShadowRatio: Number(upperShadowRatio.toFixed(2)),
653
+ upperShadowAboveMas: aboveMaList,
654
+ d0VolumeRatio: Number(d0VolumeRatio.toFixed(3)),
655
+ };
656
+ return { passed: true, score: 1, reasons, humanSummary, metrics };
657
+ }
658
+ /**
659
+ * 양음양 패턴 3 · 횡보형 (이격 관리 · 다분할)
660
+ *
661
+ * 양봉 이후 N일간 거래량 감소시키며 단기 MA 위 횡보.
662
+ */
663
+ export function matchYinYangSideways(p, candles) {
664
+ const range = p.sidewaysDaysRange ?? [3, 10];
665
+ const maxAvgVol = p.sidewaysMaxAvgVolumeRatio ?? 0.7;
666
+ const mustStayMa = p.mustStayAboveMa ?? 5;
667
+ const minBigBullishPct = p.bigBullishMinChangePct ?? 5;
668
+ const maxBigBullishPct = p.bigBullishMaxChangePct ?? 22;
669
+ const bullishMinGapPct = p.bullishMinGapPct ?? 5;
670
+ const requireGapConvergence = p.requireGapConvergence ?? true;
671
+ if (candles.length < range[1] + 10)
672
+ return fail(`candles<${range[1] + 10}`);
673
+ /* 양봉을 찾는다 (D-N) · 가장 최근 양봉 ≥ minBigBullishPct */
674
+ let bigBullishIdx = -1;
675
+ for (let i = range[0]; i <= range[1]; i++) {
676
+ const c = candles[i];
677
+ const prev = candles[i + 1];
678
+ if (!c || !prev || prev.close_krw <= 0)
679
+ continue;
680
+ if (c.close_krw <= c.open_krw)
681
+ continue;
682
+ const pct = ((c.close_krw - prev.close_krw) / prev.close_krw) * 100;
683
+ /* 20% 초과 폭등은 v2 원본 제외 조건 위반 · 양봉 후보로 부적합 */
684
+ if (pct > maxBigBullishPct)
685
+ continue;
686
+ if (pct >= minBigBullishPct) {
687
+ bigBullishIdx = i;
688
+ break;
689
+ }
690
+ }
691
+ if (bigBullishIdx < 0)
692
+ return fail(`no_big_bullish_in_${range[0]}~${range[1]}d (within ${minBigBullishPct}~${maxBigBullishPct}%)`);
693
+ const bigBullish = candles[bigBullishIdx];
694
+ const d0 = candles[0];
695
+ /* 이후 ~ D-0 횡보 + 거래량 감소 + MA 위 유지 */
696
+ const sidewaysSlice = candles.slice(0, bigBullishIdx); // [D-0, ..., D-(N-1)]
697
+ if (sidewaysSlice.length === 0)
698
+ return fail("no_sideways_period");
699
+ const avgVolDuringSideways = sidewaysSlice.reduce((s, c) => s + c.volume, 0) / sidewaysSlice.length;
700
+ const volRatio = bigBullish.volume > 0 ? avgVolDuringSideways / bigBullish.volume : 0;
701
+ if (volRatio > maxAvgVol)
702
+ return fail(`sideways_avg_vol_too_high_${volRatio.toFixed(2)}`);
703
+ // MA 위 유지 (각 시점의 MA 로 정확 비교 · D-0 ma 단일 비교는 부정확)
704
+ for (let offset = 0; offset < bigBullishIdx; offset++) {
705
+ const sliceFromOffset = candles.slice(offset);
706
+ const maAtOffset = averageClose(sliceFromOffset, mustStayMa);
707
+ if (maAtOffset > 0 && sliceFromOffset[0].close_krw <= maAtOffset) {
708
+ return fail(`broke_below_ma${mustStayMa}_at_d${offset}`);
709
+ }
710
+ }
711
+ const ma = averageClose(candles, mustStayMa);
712
+ /* ─── 이격 확대 + 수렴 검증 (taeeun-life v2 핵심) ─── */
713
+ // 양봉 시점 ma5 = candles.slice(bigBullishIdx) 의 첫 5개 평균
714
+ const bigBullishMa = averageClose(candles.slice(bigBullishIdx), mustStayMa);
715
+ if (bigBullishMa <= 0)
716
+ return fail("bigBullish_ma_invalid");
717
+ const bigBullishGapPct = ((bigBullish.close_krw - bigBullishMa) / bigBullishMa) * 100;
718
+ if (bigBullishGapPct < bullishMinGapPct) {
719
+ return fail(`bullish_gap_too_small_${bigBullishGapPct.toFixed(1)}%`);
720
+ }
721
+ // 오늘 이격
722
+ const todayGapPct = ((d0.close_krw - ma) / ma) * 100;
723
+ if (requireGapConvergence && todayGapPct >= bigBullishGapPct) {
724
+ return fail(`gap_not_converging (${bigBullishGapPct.toFixed(1)}%→${todayGapPct.toFixed(1)}%)`);
725
+ }
726
+ const gapShrinkPct = bigBullishGapPct - todayGapPct;
727
+ const reasons = [
728
+ `횡보형 [D-${bigBullishIdx + 1} 장대양봉 후 ${sidewaysSlice.length}일 횡보]`,
729
+ `[횡보 평균 거래량 ${(volRatio * 100).toFixed(0)}% · MA${mustStayMa} 위 유지]`,
730
+ `[이격 ${bigBullishGapPct.toFixed(1)}%→${todayGapPct.toFixed(1)}% · ${gapShrinkPct.toFixed(1)}%p 수렴]`,
731
+ ];
732
+ const daysAgo = bigBullishIdx + 1;
733
+ const sidewaysDays = sidewaysSlice.length;
734
+ const volPct = Math.round(volRatio * 100);
735
+ const volLabel = volPct <= 30
736
+ ? `그동안 거래량은 평소의 ${volPct}% 정도로 거의 잔잔했어요. 보통 이런 매집 후엔 한 번 더 움직임이 나오곤 해요`
737
+ : volPct <= 50
738
+ ? `그동안 거래량은 평소의 ${volPct}% 수준으로 가라앉았는데 — 매수세가 차분히 들어오고 있다는 신호예요`
739
+ : `그동안 거래량은 평소의 ${volPct}% 수준이에요`;
740
+ const humanSummary = `${daysAgo}일 전 큰 양봉 이후 ${sidewaysDays}일째 잔잔한 흐름이에요. ${volLabel}. 가격이 ${mustStayMa}일선에 점점 가까워지면서 (이격 ${bigBullishGapPct.toFixed(1)}% → ${todayGapPct.toFixed(1)}%) 매수 시점이 가까워졌어요.`;
741
+ const metrics = {
742
+ sidewaysBigBullishIdx: bigBullishIdx,
743
+ sidewaysAvgVolRatio: Number(volRatio.toFixed(3)),
744
+ sidewaysBullishGapPct: Number(bigBullishGapPct.toFixed(2)),
745
+ sidewaysTodayGapPct: Number(todayGapPct.toFixed(2)),
746
+ sidewaysGapShrinkPct: Number(gapShrinkPct.toFixed(2)),
747
+ };
748
+ return { passed: true, score: 1, reasons, humanSummary, metrics };
749
+ }
750
+ /** Bullish Engulfing: D-1 음봉 + D-0 양봉이 D-1 body 감싸기 */
751
+ function matchEngulfingBull(_p, candles) {
752
+ if (candles.length < 2)
753
+ return fail();
754
+ const d0 = candles[0];
755
+ const d1 = candles[1];
756
+ const d1IsBearish = d1.close_krw < d1.open_krw;
757
+ const d0IsBullish = d0.close_krw > d0.open_krw;
758
+ if (!d1IsBearish || !d0IsBullish)
759
+ return fail();
760
+ if (d0.open_krw > d1.close_krw)
761
+ return fail(); // D-0 시가가 D-1 종가 이하
762
+ if (d0.close_krw < d1.open_krw)
763
+ return fail(); // D-0 종가가 D-1 시가 이상
764
+ const reasons = ["장악형 양봉 (D-0 캔들이 D-1 body 포함)"];
765
+ return { passed: true, score: 1, reasons, metrics: {} };
766
+ }
767
+ /** Morning Star: D-2 음봉 → D-1 작은 몸통 → D-0 양봉 (D-2 중간 이상 회복) */
768
+ function matchMorningStar(_p, candles) {
769
+ if (candles.length < 3)
770
+ return fail();
771
+ const [d0, d1, d2] = [candles[0], candles[1], candles[2]];
772
+ if (d2.close_krw >= d2.open_krw)
773
+ return fail(); // D-2 음봉
774
+ const d1Body = Math.abs(d1.close_krw - d1.open_krw);
775
+ const d2Body = Math.abs(d2.close_krw - d2.open_krw);
776
+ if (d1Body > d2Body * 0.3)
777
+ return fail(); // D-1 작은 몸통
778
+ if (d0.close_krw <= d0.open_krw)
779
+ return fail(); // D-0 양봉
780
+ const d2Mid = (d2.open_krw + d2.close_krw) / 2;
781
+ if (d0.close_krw < d2Mid)
782
+ return fail(); // D-2 중간 위
783
+ return { passed: true, score: 1, reasons: ["샛별형 (Morning Star)"], metrics: {} };
784
+ }
785
+ /** Hammer: 긴 아래꼬리 + 짧은 몸통 + 짧은 윗꼬리 */
786
+ function matchHammer(p, candles) {
787
+ if (candles.length < 1)
788
+ return fail();
789
+ const c = candles[0];
790
+ const body = Math.abs(c.close_krw - c.open_krw);
791
+ const lowerTail = Math.min(c.open_krw, c.close_krw) - c.low_krw;
792
+ const upperTail = c.high_krw - Math.max(c.open_krw, c.close_krw);
793
+ if (body <= 0)
794
+ return fail();
795
+ const ratio = lowerTail / body;
796
+ const minRatio = p.minTailBodyRatio ?? 2;
797
+ if (ratio < minRatio)
798
+ return fail();
799
+ if (upperTail > body * 0.5)
800
+ return fail();
801
+ return { passed: true, score: 1, reasons: [`망치형 (아래꼬리/몸통=${ratio.toFixed(1)}x)`], metrics: {} };
802
+ }
803
+ /** MA Rebound: 종가가 특정 MA 부근에서 반등 (저가 < MA < 종가) */
804
+ function matchMaRebound(p, candles, _row) {
805
+ if (candles.length < p.ma)
806
+ return fail();
807
+ const c = candles[0];
808
+ const maVal = averageClose(candles, p.ma);
809
+ if (maVal <= 0)
810
+ return fail();
811
+ if (c.low_krw >= maVal)
812
+ return fail(); // 터치 확인
813
+ if (c.close_krw <= maVal)
814
+ return fail(); // 회복 확인
815
+ const bouncePct = ((c.close_krw - c.low_krw) / c.low_krw) * 100;
816
+ const minBounce = p.minBouncePct ?? 1;
817
+ if (bouncePct < minBounce)
818
+ return fail();
819
+ return {
820
+ passed: true,
821
+ score: 1,
822
+ reasons: [`MA${p.ma} 반등 (${bouncePct.toFixed(1)}%)`],
823
+ metrics: {},
824
+ };
825
+ }
826
+ function fail(reason = "unknown") {
827
+ return { passed: false, score: 0, reasons: [], metrics: {}, failReason: reason };
828
+ }
829
+ function buildHit(row, reasons, humanSummaries, ma, candles, matchedPatterns = [], rawMetrics = {}) {
830
+ const d0 = candles?.[0];
831
+ const d1 = candles?.[1];
832
+ const todayChangePct = d0 && d1 && d1.close_krw > 0 ? ((d0.close_krw - d1.close_krw) / d1.close_krw) * 100 : 0;
833
+ return {
834
+ code: row.code,
835
+ name: row.name,
836
+ market: row.market,
837
+ listCount: row.list_count,
838
+ lastPriceKrw: row.last_price_krw,
839
+ todayVolume: d0?.volume ?? 0,
840
+ todayChangePct,
841
+ matchedPatterns,
842
+ reasons,
843
+ humanSummaries,
844
+ ma,
845
+ rawMetrics: rawMetrics,
846
+ // score: 통과 패턴 수 (deprecated · LLM 이 rawMetrics 보고 평가)
847
+ score: matchedPatterns.length,
848
+ };
849
+ }
850
+ function averageClose(candles, period) {
851
+ if (candles.length < period)
852
+ return 0;
853
+ const sum = candles.slice(0, period).reduce((s, c) => s + c.close_krw, 0);
854
+ return sum / period;
855
+ }
856
+ //# sourceMappingURL=engine.js.map