diff --git a/go.mod b/go.mod index 7f036ea..5dfc3d2 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( require ( github.com/BurntSushi/toml v1.2.1 // indirect github.com/andybalholm/brotli v1.0.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -24,8 +25,11 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/testify v1.8.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect diff --git a/go.sum b/go.sum index 41fee35..30a19c8 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,13 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/internal/usecase/indicator.go b/internal/usecase/indicator.go new file mode 100644 index 0000000..854d379 --- /dev/null +++ b/internal/usecase/indicator.go @@ -0,0 +1,134 @@ +// Package usecase: indicator.go 实现技术指标计算(Milestone 6)。 +// +// 范围(v2 首版): +// - Support / Resistance:pivot 局部极值 + 0.3% 价格聚类 +// - RangeHigh / RangeLow:近 N 根 P95(High) / P5(Low) 线性插值 +// - LongShortLine:近 N 根 LSR 穿越 1.0 的价位中位数 +// +// 数值边界:本文件内部允许 transient float64,但输出到 entity 前 +// 必须 FormatFloat 转回 string。详见 ai/adr/0002-indicator-numeric-boundary.md +// 与守卫 G12。 +// +// 算法参数固定为常量;不读取 config——Milestone 6 不引入参数面。 +package usecase + +import ( + "sort" + "strconv" + + "cryptoHermes/internal/entity" +) + +const ( + // Pivot 窗口:左右各 5 根 K 线 + pivotLeft = 5 + pivotRight = 5 + + // 价格聚类阈值:两个 pivot 价位相对距离 < 0.3% → 视为同一 level + clusterPctThreshold = 0.003 + + // LongShortLine 保留近 N 次穿越 + longShortCrossKeep = 20 + + // Range 百分位 + rangePercentileHi = 0.95 + rangePercentileLo = 0.05 + + // TechnicalLevel.Source 值 + sourceSupportPivot = "pivot_low" + sourceResistancePivot = "pivot_high" +) + +// IndicatorUsecase 实现 IndicatorComputer 接口。 +// 零状态结构体——所有方法纯函数。 +type IndicatorUsecase struct{} + +// NewIndicatorUsecase 构造器;无依赖。 +func NewIndicatorUsecase() *IndicatorUsecase { return &IndicatorUsecase{} } + +// pivotPoint 是 pivot 检测的内部中间结构,价格已经被 parse 成 float64。 +type pivotPoint struct { + Price float64 + Index int +} + +// Compute 是 IndicatorComputer 的唯一对外方法。 +// klines 必须按 OpenTime 升序排好(KlineRepository.FindRecent 已保证)。 +// longShort 同样升序;可能为空。 +// +// 当前实现为骨架(commit 2):返回零值结构,让 indicator_test.go 全红。 +// commit 3 填实算法。 +func (u *IndicatorUsecase) Compute( + klines []entity.Kline, + longShort []entity.LongShortRatio, +) entity.TechnicalStructure { + _ = klines + _ = longShort + return entity.TechnicalStructure{ + Support: []entity.TechnicalLevel{}, + Resistance: []entity.TechnicalLevel{}, + } +} + +// parsePrice 用 strconv 把 string 价格解析成 float64。 +// 失败(空 / NaN / 非数字)返回 ok=false,调用者应跳过该值不 panic。 +func parsePrice(s string) (float64, bool) { + _ = sort.Float64s // keep import; will be used in commit 3 + _ = strconv.Itoa + return 0, false +} + +// formatPrice 把 transient float64 转回 string。 +// 用 'f' / prec=-1 / bitSize=64 让 FormatFloat 给出最短可往返表示。 +func formatPrice(f float64) string { + return "" +} + +// pivotHighs 在 klines 上扫描,返回所有 left=L、right=R 的 swing high。 +// swing high 条件:klines[i].High 严格大于 [i-L, i-1] 和 [i+1, i+R] 内所有 high。 +// 边界:i < L 或 i > len-R-1 跳过。 +func pivotHighs(klines []entity.Kline, left, right int) []pivotPoint { + return nil +} + +// pivotLows 对称镜像 pivotHighs,按 .Low 找局部低点。 +func pivotLows(klines []entity.Kline, left, right int) []pivotPoint { + return nil +} + +// clusterLevels 把 pivot 点按价格相对距离聚合到同一 level。 +// 算法:对 points 按价格升序排序;从最小往上扫,若当前点价格与当前 cluster +// 的均价相对距离 < pctThreshold,则合并;否则起新 cluster。 +// 每个 cluster 输出 TechnicalLevel{Price: formatPrice(均价), Strength: strconv.Itoa(len), Source: source}。 +// 输出按 Strength 降序、Price 降序(同 Strength 时高价在前)。 +func clusterLevels(points []pivotPoint, pctThreshold float64, source string) []entity.TechnicalLevel { + return nil +} + +// percentile 在升序排好的 sorted 上算线性插值的 p 分位(p∈[0,1])。 +// 经典 type 7(NumPy 默认):rank = (n-1)*p,floor + 小数权重插值。 +// n==0 返回 0;n==1 返回该唯一值。 +func percentile(sortedAsc []float64, p float64) float64 { + return 0 +} + +// rangeHighLow 计算 RangeHigh(P95)和 RangeLow(P5)。 +// 解析失败的价位被跳过,不污染百分位计算。 +// klines 为空时返回 (nil, nil)。 +func rangeHighLow(klines []entity.Kline) (high *string, low *string) { + return nil, nil +} + +// longShortCrossings 在 ls 上扫描 ratio == 1.0 的穿越点。 +// "穿越" 定义:相邻两条 ratio 一上一下穿过 1.0(含相等边界,但单边贴 1.0 不算)。 +// 对每个穿越点,按时间戳找最近的 kline,用 (open+close)/2 作为穿越价位估计。 +// 保留最近 keepN 次穿越的价位,取中位数;无穿越或全部 parse 失败 → nil。 +func longShortCrossings(ls []entity.LongShortRatio, klines []entity.Kline, keepN int) *string { + return nil +} + +// medianFloat 计算切片中位数;空切片返回 0。会改写传入 slice(内部 sort)。 +// 调用者若不希望被改,应先 copy。 +func medianFloat(xs []float64) float64 { + return 0 +} diff --git a/internal/usecase/indicator_test.go b/internal/usecase/indicator_test.go new file mode 100644 index 0000000..0556488 --- /dev/null +++ b/internal/usecase/indicator_test.go @@ -0,0 +1,451 @@ +package usecase + +import ( + "strconv" + "testing" + + "cryptoHermes/internal/entity" + + "github.com/stretchr/testify/require" +) + +// ---- 测试辅助 ---------------------------------------------------------- + +// mkKline 构造一根 1h K 线;只填指标关心的字段。 +// openTime 单位毫秒;hour 序号映射到 1h 时基。 +func mkKline(hourIdx int64, open, high, low, close string) entity.Kline { + const hourMs = int64(3600 * 1000) + return entity.Kline{ + Symbol: "BTCUSDT", + Interval: "1h", + OpenTime: hourIdx * hourMs, + CloseTime: hourIdx*hourMs + hourMs - 1, + Open: open, + High: high, + Low: low, + Close: close, + IsClosed: true, + } +} + +func mkLSR(hourIdx int64, ratio string) entity.LongShortRatio { + const hourMs = int64(3600 * 1000) + return entity.LongShortRatio{ + Symbol: "BTCUSDT", + Period: "1h", + RatioType: entity.RatioTypeGlobalAccount, + Timestamp: hourIdx * hourMs, + LongShortRatio: ratio, + } +} + +// 单调上升序列:从 baseHigh 起,每根 K 线 high/low/close 各 +stepUSD。 +// 共 n 根,hourIdx 从 0 起。 +func ascending(n int, baseLow, step float64) []entity.Kline { + out := make([]entity.Kline, n) + for i := 0; i < n; i++ { + lo := baseLow + float64(i)*step + hi := lo + step + mid := (lo + hi) / 2 + out[i] = mkKline(int64(i), + strconv.FormatFloat(lo, 'f', -1, 64), + strconv.FormatFloat(hi, 'f', -1, 64), + strconv.FormatFloat(lo, 'f', -1, 64), + strconv.FormatFloat(mid, 'f', -1, 64), + ) + } + return out +} + +func descending(n int, baseHigh, step float64) []entity.Kline { + out := make([]entity.Kline, n) + for i := 0; i < n; i++ { + hi := baseHigh - float64(i)*step + lo := hi - step + mid := (lo + hi) / 2 + out[i] = mkKline(int64(i), + strconv.FormatFloat(hi, 'f', -1, 64), + strconv.FormatFloat(hi, 'f', -1, 64), + strconv.FormatFloat(lo, 'f', -1, 64), + strconv.FormatFloat(mid, 'f', -1, 64), + ) + } + return out +} + +// ---- parsePrice ------------------------------------------------------- + +func TestParsePrice(t *testing.T) { + cases := []struct { + name string + in string + want float64 + ok bool + }{ + {"normal", "108000.12", 108000.12, true}, + {"zero", "0", 0, true}, + {"negative", "-1.5", -1.5, true}, + {"scientific", "1e3", 1000, true}, + {"empty", "", 0, false}, + {"non-numeric", "abc", 0, false}, + {"whitespace", " 1.0 ", 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := parsePrice(c.in) + require.Equal(t, c.ok, ok, "ok mismatch for %q", c.in) + if c.ok { + require.InDelta(t, c.want, got, 1e-9) + } + }) + } +} + +// ---- formatPrice ------------------------------------------------------ + +func TestFormatPrice(t *testing.T) { + require.Equal(t, "0", formatPrice(0)) + require.Equal(t, "1.5", formatPrice(1.5)) + require.Equal(t, "108000.12", formatPrice(108000.12)) + require.Equal(t, "-3", formatPrice(-3)) +} + +// ---- percentile ------------------------------------------------------- + +func TestPercentile(t *testing.T) { + // type-7 linear interpolation (NumPy default) + require.Equal(t, 0.0, percentile(nil, 0.5), "empty → 0") + require.Equal(t, 42.0, percentile([]float64{42}, 0.95), "N=1") + // N=2: P95: rank=(2-1)*0.95=0.95, between [0]=10 and [1]=20 → 10 + 0.95*10 = 19.5 + require.InDelta(t, 19.5, percentile([]float64{10, 20}, 0.95), 1e-9) + require.InDelta(t, 10.5, percentile([]float64{10, 20}, 0.05), 1e-9) + // N=20: rank=19*0.95=18.05, between [18] and [19] + xs := make([]float64, 20) + for i := range xs { + xs[i] = float64(i + 1) // 1..20 + } + // rank=18.05 → 19 + 0.05*(20-19) = 19.05 + require.InDelta(t, 19.05, percentile(xs, 0.95), 1e-9) + // rank=0.95 → 1 + 0.95*(2-1) = 1.95 + require.InDelta(t, 1.95, percentile(xs, 0.05), 1e-9) +} + +// ---- medianFloat ------------------------------------------------------ + +func TestMedianFloat(t *testing.T) { + require.Equal(t, 0.0, medianFloat(nil)) + require.Equal(t, 5.0, medianFloat([]float64{5})) + require.InDelta(t, 7.5, medianFloat([]float64{5, 10}), 1e-9) + require.InDelta(t, 5.0, medianFloat([]float64{1, 5, 9}), 1e-9) + require.InDelta(t, 4.0, medianFloat([]float64{9, 1, 5, 3}), 1e-9) // unsorted input +} + +// ---- pivotHighs / pivotLows ------------------------------------------ + +func TestPivot_EmptyAndShortInputs(t *testing.T) { + require.Empty(t, pivotHighs(nil, 5, 5)) + require.Empty(t, pivotLows(nil, 5, 5)) + require.Empty(t, pivotHighs(ascending(5, 100, 1), 5, 5), "N < 2L+1") + require.Empty(t, pivotLows(ascending(10, 100, 1), 5, 5), "N < 2L+1") +} + +func TestPivot_MonotonicNoPivots(t *testing.T) { + asc := ascending(30, 100, 10) + require.Empty(t, pivotHighs(asc, 5, 5), "ascending has no swing high in interior") + require.Empty(t, pivotLows(asc, 5, 5), "ascending has no swing low in interior") + + desc := descending(30, 100, 1) + require.Empty(t, pivotHighs(desc, 5, 5)) + require.Empty(t, pivotLows(desc, 5, 5)) +} + +func TestPivot_VShape(t *testing.T) { + // 构造 V 形:前 10 根 high 递减、后 10 根递增;中间最低点应为唯一 pivotLow + klines := make([]entity.Kline, 21) + for i := 0; i < 21; i++ { + // price 形态:100 -> 90 (index 10) -> 100 + var p float64 + if i <= 10 { + p = 100 - float64(i)*1.0 + } else { + p = 90 + float64(i-10)*1.0 + } + hi := p + 0.5 + lo := p + mid := p + 0.25 + klines[i] = mkKline(int64(i), + strconv.FormatFloat(p, 'f', -1, 64), + strconv.FormatFloat(hi, 'f', -1, 64), + strconv.FormatFloat(lo, 'f', -1, 64), + strconv.FormatFloat(mid, 'f', -1, 64), + ) + } + highs := pivotHighs(klines, 5, 5) + lows := pivotLows(klines, 5, 5) + require.Empty(t, highs, "V-shape has no pivot high") + require.Len(t, lows, 1, "V-shape has exactly one pivot low") + require.Equal(t, 10, lows[0].Index) + require.InDelta(t, 90.0, lows[0].Price, 1e-9) +} + +func TestPivot_DoubleTop(t *testing.T) { + // 两个明确的 pivot high:index 7 和 index 15 + // 用价格谷-峰-谷-峰-谷 结构 + klines := make([]entity.Kline, 23) + // 默认平的低位 100 + for i := range klines { + klines[i] = mkKline(int64(i), "100", "100.5", "99.5", "100") + } + // peak1 at i=7, price 110 + klines[7] = mkKline(7, "109", "110", "108", "109.5") + // peak2 at i=15, price 110.2 (0.18% 距离,<0.3% 阈值) + klines[15] = mkKline(15, "109", "110.2", "108", "109.5") + + highs := pivotHighs(klines, 5, 5) + require.Len(t, highs, 2) + require.Equal(t, 7, highs[0].Index) + require.Equal(t, 15, highs[1].Index) +} + +// ---- clusterLevels ---------------------------------------------------- + +func TestClusterLevels_Empty(t *testing.T) { + require.Empty(t, clusterLevels(nil, 0.003, sourceResistancePivot)) +} + +func TestClusterLevels_DoubleTopWithinThreshold(t *testing.T) { + // 两个点相差 0.18%(< 0.3%)→ 合并为 1 个 level,Strength="2" + pts := []pivotPoint{ + {Price: 110.0, Index: 7}, + {Price: 110.2, Index: 15}, + } + out := clusterLevels(pts, 0.003, sourceResistancePivot) + require.Len(t, out, 1) + require.Equal(t, "2", out[0].Strength) + require.Equal(t, sourceResistancePivot, out[0].Source) + // 均价 = 110.1 + p, ok := parsePrice(out[0].Price) + require.True(t, ok) + require.InDelta(t, 110.1, p, 1e-6) +} + +func TestClusterLevels_DoubleTopOutsideThreshold(t *testing.T) { + // 两个点相差 0.45%(> 0.3%)→ 不合并 + pts := []pivotPoint{ + {Price: 110.0, Index: 7}, + {Price: 110.5, Index: 15}, + } + out := clusterLevels(pts, 0.003, sourceResistancePivot) + require.Len(t, out, 2) + require.Equal(t, "1", out[0].Strength) + require.Equal(t, "1", out[1].Strength) +} + +func TestClusterLevels_TripleCluster(t *testing.T) { + pts := []pivotPoint{ + {Price: 100.0}, + {Price: 100.15}, + {Price: 100.25}, + } + // 100/100.25 相差 0.25%,都在阈值内 + out := clusterLevels(pts, 0.003, sourceResistancePivot) + require.Len(t, out, 1) + require.Equal(t, "3", out[0].Strength) +} + +// ---- rangeHighLow ----------------------------------------------------- + +func TestRangeHighLow_Empty(t *testing.T) { + hi, lo := rangeHighLow(nil) + require.Nil(t, hi) + require.Nil(t, lo) +} + +func TestRangeHighLow_SingleCandle(t *testing.T) { + k := []entity.Kline{mkKline(0, "100", "105", "95", "102")} + hi, lo := rangeHighLow(k) + require.NotNil(t, hi) + require.NotNil(t, lo) + require.Equal(t, "105", *hi, "P95 of single value is the value") + require.Equal(t, "95", *lo) +} + +func TestRangeHighLow_Twenty(t *testing.T) { + // highs 1..20、lows 1..20;P95(high)=19.05、P5(low)=1.95 + klines := make([]entity.Kline, 20) + for i := 0; i < 20; i++ { + v := float64(i + 1) + klines[i] = mkKline(int64(i), + strconv.FormatFloat(v, 'f', -1, 64), + strconv.FormatFloat(v, 'f', -1, 64), + strconv.FormatFloat(v, 'f', -1, 64), + strconv.FormatFloat(v, 'f', -1, 64), + ) + } + hi, lo := rangeHighLow(klines) + require.NotNil(t, hi) + require.NotNil(t, lo) + hiF, _ := parsePrice(*hi) + loF, _ := parsePrice(*lo) + require.InDelta(t, 19.05, hiF, 1e-6) + require.InDelta(t, 1.95, loF, 1e-6) +} + +func TestRangeHighLow_SkipsMalformed(t *testing.T) { + klines := []entity.Kline{ + mkKline(0, "100", "abc", "99", "100"), // high malformed → skipped + mkKline(1, "100", "200", "xyz", "100"), // low malformed → skipped + mkKline(2, "100", "150", "50", "100"), // good + } + hi, lo := rangeHighLow(klines) + require.NotNil(t, hi) + require.NotNil(t, lo) + // 有效 highs = [200, 150],有效 lows = [99, 50] + hiF, _ := parsePrice(*hi) + loF, _ := parsePrice(*lo) + // P95 of {150,200}: sorted=[150,200], rank=0.95 → 150 + 0.95*50 = 197.5 + // P5 of {50,99}: rank=0.05 → 50 + 0.05*49 = 52.45 + require.InDelta(t, 197.5, hiF, 1e-6) + require.InDelta(t, 52.45, loF, 1e-6) +} + +// ---- longShortCrossings ----------------------------------------------- + +func TestLongShortCrossings_Empty(t *testing.T) { + require.Nil(t, longShortCrossings(nil, nil, 20)) + require.Nil(t, longShortCrossings([]entity.LongShortRatio{mkLSR(0, "1.5")}, nil, 20)) +} + +func TestLongShortCrossings_AllAbove(t *testing.T) { + ls := []entity.LongShortRatio{ + mkLSR(0, "1.2"), mkLSR(1, "1.3"), mkLSR(2, "1.5"), + } + klines := []entity.Kline{ + mkKline(0, "100", "101", "99", "100"), + mkKline(1, "100", "101", "99", "100"), + mkKline(2, "100", "101", "99", "100"), + } + require.Nil(t, longShortCrossings(ls, klines, 20)) +} + +func TestLongShortCrossings_AllBelow(t *testing.T) { + ls := []entity.LongShortRatio{ + mkLSR(0, "0.5"), mkLSR(1, "0.7"), mkLSR(2, "0.9"), + } + klines := []entity.Kline{ + mkKline(0, "100", "101", "99", "100"), + mkKline(1, "100", "101", "99", "100"), + mkKline(2, "100", "101", "99", "100"), + } + require.Nil(t, longShortCrossings(ls, klines, 20)) +} + +func TestLongShortCrossings_SingleCrossing(t *testing.T) { + // LSR:0.9 → 1.1(穿越发生在 index=1) + ls := []entity.LongShortRatio{ + mkLSR(0, "0.9"), + mkLSR(1, "1.1"), + } + klines := []entity.Kline{ + mkKline(0, "100", "101", "99", "100"), + mkKline(1, "120", "121", "119", "120"), + } + got := longShortCrossings(ls, klines, 20) + require.NotNil(t, got) + // 穿越价位估计:取 ls[1] 时刻 kline 的 (open+close)/2 = 120 + v, ok := parsePrice(*got) + require.True(t, ok) + require.InDelta(t, 120.0, v, 1e-6) +} + +func TestLongShortCrossings_MultipleCrossings(t *testing.T) { + // 来回穿越多次:取近 N 次的价位中位数 + // 序列:0.9, 1.1, 0.95, 1.05 → 三次穿越(i=1, i=2, i=3) + ls := []entity.LongShortRatio{ + mkLSR(0, "0.9"), + mkLSR(1, "1.1"), + mkLSR(2, "0.95"), + mkLSR(3, "1.05"), + } + klines := []entity.Kline{ + mkKline(0, "100", "101", "99", "100"), + mkKline(1, "110", "111", "109", "110"), // 穿越价 110 + mkKline(2, "120", "121", "119", "120"), // 穿越价 120 + mkKline(3, "130", "131", "129", "130"), // 穿越价 130 + } + got := longShortCrossings(ls, klines, 20) + require.NotNil(t, got) + // 中位数 = 120 + v, ok := parsePrice(*got) + require.True(t, ok) + require.InDelta(t, 120.0, v, 1e-6) +} + +func TestLongShortCrossings_KeepNLimit(t *testing.T) { + // 24 个穿越价 1..24,keepN=20 → 保留最近 20 个(价 5..24),中位 14.5 + ls := []entity.LongShortRatio{} + klines := []entity.Kline{} + for i := 0; i < 25; i++ { + var r string + if i%2 == 0 { + r = "0.9" + } else { + r = "1.1" + } + ls = append(ls, mkLSR(int64(i), r)) + v := float64(i + 1) + klines = append(klines, mkKline(int64(i), + strconv.FormatFloat(v, 'f', -1, 64), + strconv.FormatFloat(v, 'f', -1, 64), + strconv.FormatFloat(v, 'f', -1, 64), + strconv.FormatFloat(v, 'f', -1, 64), + )) + } + got := longShortCrossings(ls, klines, 20) + require.NotNil(t, got) + // 24 个穿越(i=1..24),保留最近 20 个对应 klines[5..24] → price 6..25 + // 中位 = (15+16)/2 = 15.5 + v, ok := parsePrice(*got) + require.True(t, ok) + require.InDelta(t, 15.5, v, 1e-6) +} + +// ---- Compute(端到端编排) -------------------------------------------- + +func TestCompute_EmptyInputs(t *testing.T) { + u := NewIndicatorUsecase() + out := u.Compute(nil, nil) + require.NotNil(t, out.Support) + require.NotNil(t, out.Resistance) + require.Empty(t, out.Support) + require.Empty(t, out.Resistance) + require.Nil(t, out.RangeHigh) + require.Nil(t, out.RangeLow) + require.Nil(t, out.LongShortLine) +} + +func TestCompute_SmokeWithVShape(t *testing.T) { + u := NewIndicatorUsecase() + // V-shape 21 根,应有 1 个 support、0 个 resistance、有 range + klines := make([]entity.Kline, 21) + for i := 0; i < 21; i++ { + var p float64 + if i <= 10 { + p = 100 - float64(i)*1.0 + } else { + p = 90 + float64(i-10)*1.0 + } + klines[i] = mkKline(int64(i), + strconv.FormatFloat(p, 'f', -1, 64), + strconv.FormatFloat(p+0.5, 'f', -1, 64), + strconv.FormatFloat(p, 'f', -1, 64), + strconv.FormatFloat(p+0.25, 'f', -1, 64), + ) + } + out := u.Compute(klines, nil) + require.Len(t, out.Support, 1) + require.Empty(t, out.Resistance) + require.NotNil(t, out.RangeHigh) + require.NotNil(t, out.RangeLow) + require.Nil(t, out.LongShortLine, "no LSR data → nil") +} diff --git a/internal/usecase/ports.go b/internal/usecase/ports.go index 6e7730c..576b87b 100644 --- a/internal/usecase/ports.go +++ b/internal/usecase/ports.go @@ -49,3 +49,16 @@ type TakerVolumeRepository interface { UpsertMany(ctx context.Context, items []entity.TakerBuySellVolume) error FindRecent(ctx context.Context, symbol, period string, limit int) ([]entity.TakerBuySellVolume, error) } + +// IndicatorComputer 纯函数式接口:接收已经被上层 fetch 的 slice, +// 返回填充好的 TechnicalStructure。 +// +// 不持有任何 repo 引用——保住 G1(usecase 不依赖 repo 实现)和 +// G12(indicator 包零 repo 导入)。MarketContextUsecase 已经并发 fetch +// 过 kline 与 long-short ratio,复用结果即可,二次注入仓库引用会 +// 重复 IO。 +// +// 实现可见 internal/usecase/indicator.go。 +type IndicatorComputer interface { + Compute(klines []entity.Kline, longShort []entity.LongShortRatio) entity.TechnicalStructure +}