feat(market): Phase 1 扩展 — 更多 symbol + Bollinger/Vegas + 衍生品信号

- collector.symbols 与 supportedSymbols 加 SOL/BNB/DOGE,env-default 与 README 同步
- entity 追加 TechnicalStructure.Intervals(每周期 Bollinger + Vegas,omitempty 不破坏既有字段)与 DerivativesBundle.Signal
- 新增纯解析 usecase derivatives_signal.go:fundingBias 绝对阈值、oiSignal 用 P20 baseline + 价格方向(OI up + 价格 up/down → long/short_building,funding 不参与方向)、lsrRegime 绝对阈值;signal 数据不足走 warnings 进 DataQuality
- indicator.go 加 sma/stddev/ema/bollinger/vegas + computeIntervalTechnicals;IndicatorComputer 签名收 klines map
- harness 同步:G12 扩入 derivatives_signal.go(含 Makefile G12.5/6 + ADR-0002 适用范围 + project-map / harness-health 更新)
This commit is contained in:
dela
2026-05-24 23:19:57 +08:00
parent 622ff1d317
commit fa769331c2
16 changed files with 912 additions and 66 deletions

View File

@@ -31,9 +31,11 @@ guard:
@echo "G1 (no signing)..." && ! grep -rqi --include="*.go" "apikey\|x-mbx-apikey\|hmac\|secret_key\|api_secret" .
@echo "G6 (boundary)..." && ! grep -rq "repo/webapi/binance\|repo/persistent" internal/usecase internal/controller
@echo "G11 (ctrl derivs)..." && ! grep -rqn "FundingRepo\|OIRepo\|LSRepo\|TakerRepo" internal/controller
@echo "G12.1 (usecase float)..." && ! { grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -v indicator | grep -q . ; }
@echo "G12.1 (usecase float)..." && ! { grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -vE "indicator|derivatives_signal" | grep -q . ; }
@echo "G12.2 (indicator no repo)..." && ! grep -q "internal/repo" internal/usecase/indicator.go
@echo "G12.3 (indicator no DB)..." && ! grep -qE "binance|postgres|pgx" internal/usecase/indicator.go
@echo "G12.5 (derivatives_signal no repo)..." && ! grep -q "internal/repo" internal/usecase/derivatives_signal.go
@echo "G12.6 (derivatives_signal no DB)..." && ! grep -qE "binance|postgres|pgx" internal/usecase/derivatives_signal.go
@echo "G12.4 (entity no float)..." && ! grep -rqn --include="*.go" "float64\|float32" internal/entity
@echo "G13.1 (compose requires password)..." && grep -qx 'POSTGRES_PASSWORD=' .env.example
@echo "G13.2 (compose fixed app port)..." && grep -q 'APP_HOST_PORT' docker-compose.yml && grep -q 'APP_PORT: "8080"' docker-compose.yml && ! grep -q '$${APP_PORT' docker-compose.yml

View File

@@ -66,12 +66,11 @@ make migrate-up
第一次启动时 DB 是空的,直接调 `/v1/market/context` 会触发回源 Binance。建议先回填
```bash
make backfill ARGS="--symbol BTCUSDT --interval 15m --limit 500"
make backfill ARGS="--symbol BTCUSDT --interval 1h --limit 500"
make backfill ARGS="--symbol BTCUSDT --interval 4h --limit 500"
make backfill ARGS="--symbol BTCUSDT --interval 1d --limit 500"
make backfill ARGS="--symbol BTCUSDT --interval 1w --limit 500"
# ETHUSDT 同上
for s in BTCUSDT ETHUSDT SOLUSDT BNBUSDT DOGEUSDT; do
for i in 15m 1h 4h 1d 1w; do
make backfill ARGS="--symbol $s --interval $i --limit 500"
done
done
```
也可以指定起止时间做大段回填:
@@ -168,8 +167,9 @@ docker compose logs -f postgres
启动后 DB 是空的;调用 `/v1/market/context` 会 fallback 到 Binance带 warnings。要立刻获得满载历史进容器跑 backfill
```bash
docker compose exec app /app/backfill --symbol BTCUSDT --interval 1h --limit 500
docker compose exec app /app/backfill --symbol ETHUSDT --interval 1h --limit 500
for s in BTCUSDT ETHUSDT SOLUSDT BNBUSDT DOGEUSDT; do
docker compose exec app /app/backfill --symbol $s --interval 1h --limit 500
done
# 其它 interval 同理
```
@@ -222,7 +222,7 @@ postgres:
collector:
enabled: true # 设为 false 可只跑只读 API不开 cron
symbols: [BTCUSDT, ETHUSDT]
symbols: [BTCUSDT, ETHUSDT, SOLUSDT, BNBUSDT, DOGEUSDT]
intervals: [15m, 1h, 4h, 1d, 1w]
default_limit: 500
```

View File

@@ -3,7 +3,7 @@
**状态**Accepted
**日期**2026-05-24
**决策者**:项目维护者
**适用范围**`internal/usecase/indicator.go` 及其后续衍生指标实现
**适用范围**`internal/usecase/indicator.go``internal/usecase/derivatives_signal.go` 及其后续同形态的纯解析 usecase 实现
---
@@ -33,10 +33,11 @@ Milestone 6 引入技术指标计算pivot S/R、percentile range、LSR 穿越
边界规则:
- `float64` **只**出现在 `internal/usecase/indicator.go` 内部
- 进入 `entity.TechnicalLevel` / `entity.TechnicalStructure` 前必须 `strconv.FormatFloat(f, 'f', -1, 64)` 转回 string
- `float64` **只**出现在 `internal/usecase/indicator.go``internal/usecase/derivatives_signal.go` 等"纯解析 usecase 文件"内部
- 进入任何 entity 字段(TechnicalLevel / TechnicalStructure / DerivativesSignal 等)前必须 `strconv.FormatFloat(f, 'f', -1, 64)` 转回 string;类别型字段(如 `crowded_long`)用 string 字面量
- entity / DTO / persistence / transport 全部保持 string不变
- 任何其它 usecase 文件出现 `float64` 视为违规
- 新增同形态文件时:必须同步扩 Makefile 的 G12.1 白名单与 G12.5/6 子规则
这是 `ai/risk-guardrails.md` **G2 例外条款**"纯统计/分析(如指标计算的中间过程)可以用 float64但结果落库或返回 API 时必须转回 string")的具体落地。守卫 G12 把这条例外细化成可机械验证的命令。

View File

@@ -60,6 +60,7 @@
**现状**MVP 阶段重点是端到端可用,未补单测覆盖率。
**影响**refactor 时风险偏高。
**升级时机**Milestone 6 引入指标计算(计算正确性 critical先补 mapper / repo 的 table-driven 测试。
**2026-05-24 更新**Phase 1 扩展symbols + Bollinger/Vegas/derivatives signal落地时`indicator_test.go` 和新建的 `derivatives_signal_test.go` 都铺了表驱动 + 边界用例(含 funding-positive + price-down 的回归用例。mapper / repo 的覆盖率仍是 partial留待下一次纯解析工作之外的 PR 再补。
### W5. 部分 docs/* 未提交到 git
**现状**`docs/dev.md` 是 untrackedgit status 显示 `?? docs/`)。
@@ -97,6 +98,7 @@ docs/dev.md ← 完整设计文档(源头)
ai/project-map.md ← 仓库结构与扩展点
ai/risk-guardrails.md ← G1-G13 守卫规则
ai/adr/0001-architecture-foundations.md ← 9 条架构基础决策
ai/adr/0002-indicator-numeric-boundary.md ← float64 transient 边界 + G12 落地
ai/task-templates.md ← 6 种任务模板
ai/harness-health.md ← 本文件
```

View File

@@ -72,6 +72,7 @@ Clean Architecture 四层。**依赖方向单向:`controller → usecase → e
- **价格/成交量字段全部 `string`**,避免 float64 精度问题。落库时 PG 转 `NUMERIC(36,18)`
- `Kline``IsClosed bool` 字段——未收线 K 线 (`close_time > now()`) 由 mapper 自动判定。
- `LongShortRatio` 有常量 `RatioTypeGlobalAccount` / `RatioTypeTopTraderPosition` / `RatioTypeTopTraderAccount`
- `market_context.go` 还承载技术结构与衍生品信号的输出类型:`TechnicalStructure`(含 `Intervals map[string]IntervalTechnicals`,新增字段对消费方完全可选)、`Bollinger``Vegas``DerivativesSignal``fundingBias` / `oiSignal` / `lsrRegime`)。所有数值仍是 string。
**不依赖**fiber、pgx、binance、任何外部库。可以从这里 import 到任何其他层。
@@ -81,8 +82,11 @@ Clean Architecture 四层。**依赖方向单向:`controller → usecase → e
- `MarketDataProvider`K线、ticker24h
- `DerivativesProvider`funding/OI/多空比/taker volume 共 7 个方法)
- `KlineRepository` / `FundingRepository` / `OpenInterestRepository` / `LongShortRatioRepository` / `TakerVolumeRepository`
- `market_context.go`:聚合 `/v1/market/context`。errgroup 并发拉 snapshot/funding/OIDB K 线不足 200 根回源 Binance 补,异步写回。
- `IndicatorComputer`pivot S/R + range + LSR 中位 + 每周期 Bollinger/Vegas
- `DerivativesSignalComputer`funding/OI/LSR → fundingBias/oiSignal/lsrRegime返回 warnings 由 caller 合并到 DataQuality
- `market_context.go`:聚合 `/v1/market/context`。errgroup 并发拉 snapshot/funding/OIDB K 线不足 200 根回源 Binance 补,异步写回。`supportedSymbols` 白名单当前为 BTC/ETH/SOL/BNB/DOGE USDT 五个。
- `market_query.go``/v1/market/derivatives` 后端。`GetDerivatives(symbol, period)` 并发拉 current funding/OI + 串行查 funding/OI/多空比历史,错误降级为 warnings 返回。
- `indicator.go``derivatives_signal.go`**纯解析 usecase**,零 repo 依赖。允许 transient `float64`G12 例外),输出转回 string 写入 entity。新增同形态文件时必须同步扩 Makefile G12.1 白名单与 G12.5/6 子规则,参考 `ai/adr/0002-indicator-numeric-boundary.md`
**约束**grep 可验证):
```bash

View File

@@ -195,25 +195,29 @@ grep -rn "FundingRepo\|OIRepo\|LSRepo\|TakerRepo" internal/controller
## G12. 指标计算的 float64 边界
**规则**`float64``internal/usecase/` 下**只允许出现在 `indicator.go` 内部**作为 transient 计算变量;进入 `entity` / 返回上层之前必须 `strconv.FormatFloat(_, 'f', -1, 64)` 转回 string。`internal/usecase/indicator.go` 同时**禁止** import `internal/repo/...` 的任何子包。
**规则**`float64``internal/usecase/` 下**只允许出现在纯解析 usecase 文件**(当前为 `indicator.go``derivatives_signal.go`)内部作为 transient 计算变量;进入 `entity` / 返回上层之前必须 `strconv.FormatFloat(_, 'f', -1, 64)` 转回 string。这些文件同时**禁止** import `internal/repo/...` 的任何子包。
**为什么**G2 已经预留"指标计算中间过程可用 float64"的例外但没有可机械验证的边界。Milestone 6 之后会有更多指标加入,必须把例外明文化、可 grep 化,避免某天 float64 悄悄出现在 entity 字段或被 repo 持久化。决策细节见 `ai/adr/0002-indicator-numeric-boundary.md`
**为什么**G2 已经预留"指标计算中间过程可用 float64"的例外但没有可机械验证的边界。Milestone 6 之后会有更多指标和衍生品信号加入,必须把例外明文化、可 grep 化,避免某天 float64 悄悄出现在 entity 字段或被 repo 持久化。决策细节见 `ai/adr/0002-indicator-numeric-boundary.md`
**怎么验证**
**新增同形态文件时**:必须同步扩 Makefile 的 G12.1 白名单与 G12.5/6 子规则(参考 `derivatives_signal.go` 接入的方式),并把文件名加入本规则的"当前为"括注。
**怎么验证**(由 `make guard` 自动跑):
```bash
# usecase 下除 indicator.go 之外不得出现 float64
grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -v indicator
# G12.1usecase 下除白名单文件外不得出现 float64
grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -vE "indicator|derivatives_signal"
# 期望:无输出
# indicator.go 不得 import 任何 repo 子包
# G12.2/G12.3indicator.go 不得 import repo 子包,也不得 import binance/postgres/pgx
grep -n "internal/repo" internal/usecase/indicator.go
# 期望:无输出
# indicator.go 不得 import binance / postgres / pgx
grep -nE "binance|postgres|pgx" internal/usecase/indicator.go
# 期望:无输出(注释中的 ADR 引用不算)
# entity 字段任何位置不得 float
# G12.5/G12.6derivatives_signal.go 同上
grep -n "internal/repo" internal/usecase/derivatives_signal.go
grep -nE "binance|postgres|pgx" internal/usecase/derivatives_signal.go
# 期望:无输出
# G12.4entity 字段任何位置不得 float
grep -rn --include="*.go" "float64\|float32" internal/entity
# 期望:无输出
```

View File

@@ -42,7 +42,7 @@ type Postgres struct {
type Collector struct {
Enabled bool `yaml:"enabled" env-default:"true"`
Symbols []string `yaml:"symbols" env-default:"BTCUSDT,ETHUSDT"`
Symbols []string `yaml:"symbols" env-default:"BTCUSDT,ETHUSDT,SOLUSDT,BNBUSDT,DOGEUSDT"`
Intervals []string `yaml:"intervals" env-default:"15m,1h,4h,1d,1w"`
DefaultLimit int `yaml:"default_limit" env-default:"500"`
}

View File

@@ -25,6 +25,9 @@ collector:
symbols:
- BTCUSDT
- ETHUSDT
- SOLUSDT
- BNBUSDT
- DOGEUSDT
intervals:
- 15m
- 1h

View File

@@ -56,6 +56,7 @@ func Run(cfg *config.Config, log *slog.Logger) error {
oiRepo,
lsRepo,
usecase.NewIndicatorUsecase(),
usecase.NewDerivativesSignalUsecase(),
log,
)

View File

@@ -15,6 +15,15 @@ type DerivativesBundle struct {
OpenInterest OpenInterestBundle `json:"openInterest"`
LongShortRatio LongShortBundle `json:"longShortRatio"`
TakerBuySellVolume []TakerBuySellVolume `json:"takerBuySellVolume"`
Signal *DerivativesSignal `json:"signal,omitempty"`
}
// DerivativesSignal 把 funding / OI / LSR 的原始数字翻译成可读语义,
// 供上游策略引擎直接消费。任一子字段为空字符串表示数据不足以判定。
type DerivativesSignal struct {
FundingBias string `json:"fundingBias,omitempty"` // crowded_long / crowded_short / neutral
OISignal string `json:"oiSignal,omitempty"` // long_building / short_building / deleveraging / stable
LSRRegime string `json:"lsrRegime,omitempty"` // retail_long_heavy / retail_short_heavy / balanced
}
type FundingBundle struct {
@@ -38,6 +47,31 @@ type TechnicalStructure struct {
RangeHigh *string `json:"rangeHigh"`
RangeLow *string `json:"rangeLow"`
LongShortLine *string `json:"longShortLine"`
Intervals map[string]IntervalTechnicals `json:"intervals"`
}
type IntervalTechnicals struct {
Bollinger *Bollinger `json:"bollinger,omitempty"`
Vegas *Vegas `json:"vegas,omitempty"`
}
// Bollinger 是 20 周期、2 倍标准差的布林带。
// BandwidthPct = (upper-lower)/mid * 100Squeeze 取 tight/normal/expanded。
type Bollinger struct {
Mid string `json:"mid"`
Upper string `json:"upper"`
Lower string `json:"lower"`
BandwidthPct string `json:"bandwidthPct"`
Squeeze string `json:"squeeze"`
}
// Vegas 通道EMA12/144/169 三条线 + 趋势判定。
// Trend: bull / bear / range按三条线的堆叠顺序判定。
type Vegas struct {
EMA12 string `json:"ema12"`
EMA144 string `json:"ema144"`
EMA169 string `json:"ema169"`
Trend string `json:"trend"`
}
type TechnicalLevel struct {

View File

@@ -0,0 +1,215 @@
// Package usecase: derivatives_signal.go 把 funding/OI/LSR 三个原始
// 衍生品维度翻译成上游策略引擎可直接消费的语义字段。
//
// 与 indicator.go 同款约束:纯函数 usecase零 repo 依赖G1/G12 守卫。
// 价格 / 比例值在文件内部允许 transient float64不写回 entity。
//
// 判定策略(混合):
// - FundingBias 用绝对阈值funding rate 本身是百分比,跨 symbol 可比)
// - OISignal 用滚动分位OI 数量级跨 symbol 差异大,需归一化)
// + 价格方向OI up + price up/down 判 long/short_building
// funding 不参与,避免价格下跌但 funding 仍为正时误判)
// - LSRRegime 用绝对阈值(比例值,可比)
//
// 数据不足的子判定通过 warnings 显式告知调用方,调用方应把 warnings
// 合并到 DataQuality.Warnings 让消费端看到。
package usecase
import (
"fmt"
"math"
"sort"
"strconv"
"cryptoHermes/internal/entity"
)
const (
// FundingBias: 单期 funding rate8h 结算)超过 ±0.05% 视为拥挤
fundingCrowdedAbs = 0.0005
// OISignal: 用最近 N 根 OI 历史的 P20 做基线,
// |latest / p20 - 1| < oiStableBandPct 视为 stable
// 历史不足 oiMinHistoryPoints 根则不出该字段。
oiRollingP = 0.20
oiStableBandPct = 0.02
oiMinHistoryPoints = 24
// OI 在涨时判 long/short_building 需要价格方向:
// 取末根 close 与 oiPriceLookback 根之前的 close 比较。
// 1h K 线 × 24 ≈ 与 OI baseline 同窗口(最近 24h
oiPriceLookback = 24
// LSRRegime: 全局多空账户比阈值
lsrRetailHi = 1.5
lsrRetailLo = 0.67
)
// DerivativesSignalUsecase 实现 DerivativesSignalComputer。
type DerivativesSignalUsecase struct{}
// NewDerivativesSignalUsecase 构造器;无依赖。
func NewDerivativesSignalUsecase() *DerivativesSignalUsecase {
return &DerivativesSignalUsecase{}
}
// Compute 把三个维度合并成一个 *DerivativesSignal + warnings。
// 三个子判定相互独立:任何一项数据不足,对应字段留空 + 一条 warning
// 不影响其它字段。全部都判不出来时返回 nil signal。
func (u *DerivativesSignalUsecase) Compute(
currentFunding *entity.FundingRate,
oiHistory []entity.OpenInterest,
globalLSR []entity.LongShortRatio,
primaryKlines []entity.Kline,
) (*entity.DerivativesSignal, []string) {
var warnings []string
fb, fbWarn := fundingBias(currentFunding)
if fbWarn != "" {
warnings = append(warnings, fbWarn)
}
oi, oiWarn := oiSignal(oiHistory, primaryKlines)
if oiWarn != "" {
warnings = append(warnings, oiWarn)
}
lsr, lsrWarn := lsrRegime(globalLSR)
if lsrWarn != "" {
warnings = append(warnings, lsrWarn)
}
if fb == "" && oi == "" && lsr == "" {
return nil, warnings
}
return &entity.DerivativesSignal{
FundingBias: fb,
OISignal: oi,
LSRRegime: lsr,
}, warnings
}
// fundingBias 用绝对阈值判定当前 funding 是否"拥挤"。
// 解析失败 / 缺数据 → ("", warning)。
func fundingBias(f *entity.FundingRate) (string, string) {
if f == nil {
return "", "signal.fundingBias: current funding unavailable"
}
v, ok := parseFloat(f.FundingRate)
if !ok {
return "", "signal.fundingBias: current funding rate unparseable"
}
switch {
case v > fundingCrowdedAbs:
return "crowded_long", ""
case v < -fundingCrowdedAbs:
return "crowded_short", ""
default:
return "neutral", ""
}
}
// oiSignal 用 OI 历史 P20 做基线,与最新值比较:
// - |pct| < oiStableBandPct → stable
// - pct < 0 → deleveraging
// - pct > 0 → 看 1h 价格方向latest close vs ~24h 前):
// price up → long_buildingprice down → short_building
// 价格 K 线不足 oiPriceLookback+1 根则留空 + warning不靠 funding 兜底,
// funding 是拥挤度而非新仓方向,会误判)。
//
// 历史不足 oiMinHistoryPoints 根或全部 parse 失败 → ("", warning)。
func oiSignal(history []entity.OpenInterest, primaryKlines []entity.Kline) (string, string) {
if len(history) < oiMinHistoryPoints {
return "", fmt.Sprintf("signal.oiSignal: OI history insufficient (have %d, need %d)", len(history), oiMinHistoryPoints)
}
values := make([]float64, 0, len(history))
for _, p := range history {
if v, ok := parseFloat(p.OpenInterest); ok && v > 0 {
values = append(values, v)
}
}
if len(values) < oiMinHistoryPoints {
return "", fmt.Sprintf("signal.oiSignal: OI history mostly unparseable (parsed %d of %d)", len(values), len(history))
}
latest := values[len(values)-1]
sorted := make([]float64, len(values))
copy(sorted, values)
sort.Float64s(sorted)
baseline := percentile(sorted, oiRollingP)
if baseline <= 0 {
return "", "signal.oiSignal: OI baseline non-positive"
}
pct := (latest - baseline) / baseline
if math.Abs(pct) < oiStableBandPct {
return "stable", ""
}
if pct < 0 {
return "deleveraging", ""
}
// OI 在涨:用价格方向判定多头建仓 / 空头建仓
dir, warn := priceDirection(primaryKlines, oiPriceLookback)
switch dir {
case "up":
return "long_building", ""
case "down":
return "short_building", ""
default:
return "", warn
}
}
// priceDirection 比较末根 close 与回看 lookback 根之前的 close。
// 返回 "up" / "down" / ""(数据不足或解析失败 → 带 warning
// flat严格相等视为 down 的退化情况——但这种概率几乎为零,
// 与其引入 stable 分支让 OI 信号变三态,不如归到 down 简化语义。
func priceDirection(klines []entity.Kline, lookback int) (string, string) {
need := lookback + 1
if len(klines) < need {
return "", fmt.Sprintf("signal.oiSignal: primary klines insufficient for price direction (have %d, need %d)", len(klines), need)
}
latestIdx := len(klines) - 1
priorIdx := latestIdx - lookback
latest, ok1 := parsePrice(klines[latestIdx].Close)
prior, ok2 := parsePrice(klines[priorIdx].Close)
if !ok1 || !ok2 || prior == 0 {
return "", "signal.oiSignal: primary klines close unparseable"
}
if latest > prior {
return "up", ""
}
return "down", ""
}
// lsrRegime 用最新一根全局多空账户比判定散户拥挤方向。
// 切片为空或末尾解析失败 → ("", warning)。
func lsrRegime(ls []entity.LongShortRatio) (string, string) {
if len(ls) == 0 {
return "", "signal.lsrRegime: global LSR empty"
}
last := ls[len(ls)-1]
v, ok := parseFloat(last.LongShortRatio)
if !ok {
return "", "signal.lsrRegime: latest LSR unparseable"
}
switch {
case v > lsrRetailHi:
return "retail_long_heavy", ""
case v < lsrRetailLo:
return "retail_short_heavy", ""
default:
return "balanced", ""
}
}
// parseFloat 是 derivatives_signal 内部用的 float 解析。
// 比 parsePrice 多接受负数funding 可负NaN/Inf/空串视为失败。
func parseFloat(s string) (float64, bool) {
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
if math.IsNaN(f) || math.IsInf(f, 0) {
return 0, false
}
return f, true
}

View File

@@ -0,0 +1,280 @@
package usecase
import (
"strconv"
"testing"
"cryptoHermes/internal/entity"
"github.com/stretchr/testify/require"
)
func mkOI(idx int, v string) entity.OpenInterest {
return entity.OpenInterest{
Symbol: "BTCUSDT",
Period: "1h",
Timestamp: int64(idx) * 3600 * 1000,
OpenInterest: v,
}
}
func mkLSRAt(idx int, ratio string) entity.LongShortRatio {
return entity.LongShortRatio{
Symbol: "BTCUSDT",
Period: "1h",
RatioType: entity.RatioTypeGlobalAccount,
Timestamp: int64(idx) * 3600 * 1000,
LongShortRatio: ratio,
}
}
// klinesWithLastClose 构造 n 根 1h K 线,全部 close = baseClose
// 最后一根 close = lastClose用来精准控制价格方向
// 价格趋势靠"末根 vs 末根-lookback 根"判定,中间不影响。
func klinesWithLastClose(n int, baseClose, lastClose float64) []entity.Kline {
out := make([]entity.Kline, n)
for i := 0; i < n; i++ {
c := baseClose
if i == n-1 {
c = lastClose
}
out[i] = mkKline(int64(i),
strconv.FormatFloat(c, 'f', -1, 64),
strconv.FormatFloat(c+0.5, 'f', -1, 64),
strconv.FormatFloat(c-0.5, 'f', -1, 64),
strconv.FormatFloat(c, 'f', -1, 64),
)
}
return out
}
// ---- FundingBias ------------------------------------------------------
func TestFundingBias_Cases(t *testing.T) {
tests := []struct {
name string
rate string
want string
}{
{"crowded_long", "0.001", "crowded_long"},
{"crowded_short", "-0.001", "crowded_short"},
{"neutral_zero", "0", "neutral"},
{"neutral_small_pos", "0.0001", "neutral"},
{"neutral_small_neg", "-0.0001", "neutral"},
{"boundary_high", "0.0005", "neutral"}, // 严格 >
{"boundary_low", "-0.0005", "neutral"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, warn := fundingBias(&entity.FundingRate{FundingRate: tc.rate})
require.Equal(t, tc.want, got)
require.Empty(t, warn)
})
}
}
func TestFundingBias_NilAndBadInput(t *testing.T) {
got, warn := fundingBias(nil)
require.Equal(t, "", got)
require.NotEmpty(t, warn)
got, warn = fundingBias(&entity.FundingRate{FundingRate: ""})
require.Equal(t, "", got)
require.NotEmpty(t, warn)
got, warn = fundingBias(&entity.FundingRate{FundingRate: "not-a-number"})
require.Equal(t, "", got)
require.NotEmpty(t, warn)
}
// ---- OISignal ---------------------------------------------------------
func makeOIHistory(values []float64) []entity.OpenInterest {
out := make([]entity.OpenInterest, len(values))
for i, v := range values {
out[i] = mkOI(i, formatPrice(v))
}
return out
}
// 25+ 根 K 线方便 priceDirection 拿到(需要 lookback+1 = 25 根)。
func sufficientKlines(latestClose float64) []entity.Kline {
return klinesWithLastClose(25, 100, latestClose)
}
func TestOISignal_InsufficientHistory(t *testing.T) {
values := make([]float64, 23) // 23 < oiMinHistoryPoints(24)
for i := range values {
values[i] = 1000
}
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(100))
require.Equal(t, "", got)
require.Contains(t, warn, "OI history insufficient")
}
func TestOISignal_Stable(t *testing.T) {
values := make([]float64, 30)
for i := range values {
values[i] = 1000
}
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(100))
require.Equal(t, "stable", got)
require.Empty(t, warn)
}
func TestOISignal_Deleveraging(t *testing.T) {
// 前 25 根 1000最后 5 根降到 800OI 在跌,不看价格
values := make([]float64, 30)
for i := 0; i < 25; i++ {
values[i] = 1000
}
for i := 25; i < 30; i++ {
values[i] = 800
}
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(100))
require.Equal(t, "deleveraging", got)
require.Empty(t, warn)
}
func TestOISignal_LongBuilding_PriceUp(t *testing.T) {
// OI 涨 + 价格涨(末根 110 > base 100→ long_building
values := make([]float64, 30)
for i := 0; i < 25; i++ {
values[i] = 1000
}
for i := 25; i < 30; i++ {
values[i] = 1200
}
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(110))
require.Equal(t, "long_building", got)
require.Empty(t, warn)
}
func TestOISignal_ShortBuilding_PriceDown(t *testing.T) {
// OI 涨 + 价格跌(末根 90 < base 100→ short_building
values := make([]float64, 30)
for i := 0; i < 25; i++ {
values[i] = 1000
}
for i := 25; i < 30; i++ {
values[i] = 1200
}
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(90))
require.Equal(t, "short_building", got)
require.Empty(t, warn)
}
func TestOISignal_OIUp_KlinesInsufficient(t *testing.T) {
// OI 涨但价格 K 线只有 10 根,无法判定方向
values := make([]float64, 30)
for i := 0; i < 25; i++ {
values[i] = 1000
}
for i := 25; i < 30; i++ {
values[i] = 1200
}
got, warn := oiSignal(makeOIHistory(values), klinesWithLastClose(10, 100, 110))
require.Equal(t, "", got)
require.Contains(t, warn, "primary klines insufficient")
}
func TestOISignal_FundingIgnoredForDirection(t *testing.T) {
// 关键回归用例funding 为正但价格下跌
// 旧逻辑会误判 long_building新逻辑应判 short_building
values := make([]float64, 30)
for i := 0; i < 25; i++ {
values[i] = 1000
}
for i := 25; i < 30; i++ {
values[i] = 1200
}
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(90))
require.Equal(t, "short_building", got)
require.Empty(t, warn)
}
// ---- LSRRegime --------------------------------------------------------
func TestLSRRegime_Cases(t *testing.T) {
tests := []struct {
name string
last string
want string
}{
{"retail_long_heavy", "1.6", "retail_long_heavy"},
{"retail_short_heavy", "0.5", "retail_short_heavy"},
{"balanced_mid", "1.0", "balanced"},
{"balanced_near_hi", "1.5", "balanced"},
{"balanced_near_lo", "0.67", "balanced"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, warn := lsrRegime([]entity.LongShortRatio{mkLSRAt(0, tc.last)})
require.Equal(t, tc.want, got)
require.Empty(t, warn)
})
}
}
func TestLSRRegime_UsesLatest(t *testing.T) {
got, warn := lsrRegime([]entity.LongShortRatio{
mkLSRAt(0, "1.6"),
mkLSRAt(1, "0.5"),
})
require.Equal(t, "retail_short_heavy", got)
require.Empty(t, warn)
}
func TestLSRRegime_Empty(t *testing.T) {
got, warn := lsrRegime(nil)
require.Equal(t, "", got)
require.NotEmpty(t, warn)
}
// ---- Compute编排 ---------------------------------------------------
func TestCompute_AllDataPresent(t *testing.T) {
u := NewDerivativesSignalUsecase()
values := make([]float64, 30)
for i := 0; i < 25; i++ {
values[i] = 1000
}
for i := 25; i < 30; i++ {
values[i] = 1200
}
got, warnings := u.Compute(
&entity.FundingRate{FundingRate: "0.001"},
makeOIHistory(values),
[]entity.LongShortRatio{mkLSRAt(0, "1.6")},
sufficientKlines(110),
)
require.NotNil(t, got)
require.Equal(t, "crowded_long", got.FundingBias)
require.Equal(t, "long_building", got.OISignal)
require.Equal(t, "retail_long_heavy", got.LSRRegime)
require.Empty(t, warnings)
}
func TestCompute_AllEmpty_ReturnsNilWithWarnings(t *testing.T) {
u := NewDerivativesSignalUsecase()
got, warnings := u.Compute(nil, nil, nil, nil)
require.Nil(t, got)
// 三个子判定都失败,应有 3 条 warning
require.Len(t, warnings, 3)
}
func TestCompute_PartialData(t *testing.T) {
u := NewDerivativesSignalUsecase()
got, warnings := u.Compute(
&entity.FundingRate{FundingRate: "0.001"},
nil,
nil,
nil,
)
require.NotNil(t, got)
require.Equal(t, "crowded_long", got.FundingBias)
require.Equal(t, "", got.OISignal)
require.Equal(t, "", got.LSRRegime)
// OI + LSR 缺数据,应有 2 条 warning
require.Len(t, warnings, 2)
}

View File

@@ -4,6 +4,8 @@
// - Support / Resistancepivot 局部极值 + 0.3% 价格聚类
// - RangeHigh / RangeLow近 N 根 P95(High) / P5(Low) 线性插值
// - LongShortLine近 N 根 LSR 穿越 1.0 的价位中位数
// - Bollingerper intervalSMA20 + 2σ
// - Vegasper intervalEMA12/144/169 三线 + 趋势判定
//
// 数值边界:本文件内部允许 transient float64但输出到 entity 前
// 必须 FormatFloat 转回 string。详见 ai/adr/0002-indicator-numeric-boundary.md
@@ -38,6 +40,17 @@ const (
// TechnicalLevel.Source 值
sourceSupportPivot = "pivot_low"
sourceResistancePivot = "pivot_high"
// Bollinger周期 20、倍数 2、Squeeze 阈值按 BandwidthPct百分比
bbPeriod = 20
bbStdMult = 2.0
bbSqueezeTight = 4.0
bbSqueezeExpanded = 12.0
// Vegas 通道 EMA 周期
vegasFast = 12
vegasMid = 144
vegasSlow = 169
)
// IndicatorUsecase 实现 IndicatorComputer 接口。
@@ -54,14 +67,18 @@ type pivotPoint struct {
}
// Compute 是 IndicatorComputer 的唯一对外方法。
// klines 必须按 OpenTime 升序排好KlineRepository.FindRecent 已保证)。
// klinesByInterval 每个 value 必须按 OpenTime 升序排好KlineRepository.FindRecent 已保证)。
// primaryInterval 指定 support/resistance/range/longShortLine 用哪个周期;
// 其它周期(包括 primary 自己)都会参与 Intervals 里的 Bollinger / Vegas。
// longShort 同样升序;可能为空。
func (u *IndicatorUsecase) Compute(
klines []entity.Kline,
klinesByInterval map[string][]entity.Kline,
primaryInterval string,
longShort []entity.LongShortRatio,
) entity.TechnicalStructure {
highs := pivotHighs(klines, pivotLeft, pivotRight)
lows := pivotLows(klines, pivotLeft, pivotRight)
primary := klinesByInterval[primaryInterval]
highs := pivotHighs(primary, pivotLeft, pivotRight)
lows := pivotLows(primary, pivotLeft, pivotRight)
resistance := clusterLevels(highs, clusterPctThreshold, sourceResistancePivot)
support := clusterLevels(lows, clusterPctThreshold, sourceSupportPivot)
if support == nil {
@@ -70,17 +87,45 @@ func (u *IndicatorUsecase) Compute(
if resistance == nil {
resistance = []entity.TechnicalLevel{}
}
hi, lo := rangeHighLow(klines)
lsLine := longShortCrossings(longShort, klines, longShortCrossKeep)
hi, lo := rangeHighLow(primary)
lsLine := longShortCrossings(longShort, primary, longShortCrossKeep)
intervals := make(map[string]entity.IntervalTechnicals, len(klinesByInterval))
for iv, ks := range klinesByInterval {
intervals[iv] = computeIntervalTechnicals(ks)
}
return entity.TechnicalStructure{
Support: support,
Resistance: resistance,
RangeHigh: hi,
RangeLow: lo,
LongShortLine: lsLine,
Intervals: intervals,
}
}
// computeIntervalTechnicals 在单周期 K 线上算 Bollinger 与 Vegas。
// 任一项数据不足则对应字段为 nil。
func computeIntervalTechnicals(klines []entity.Kline) entity.IntervalTechnicals {
closes := parseCloses(klines)
return entity.IntervalTechnicals{
Bollinger: bollinger(closes),
Vegas: vegas(closes),
}
}
// parseCloses 提取 close 价位,跳过解析失败的根。
func parseCloses(klines []entity.Kline) []float64 {
out := make([]float64, 0, len(klines))
for _, k := range klines {
if v, ok := parsePrice(k.Close); ok {
out = append(out, v)
}
}
return out
}
// parsePrice 用 strconv 把 string 价格解析成 float64。
// 失败(空 / 非数字 / 含空格)返回 ok=false调用者应跳过该值不 panic。
// NaN 与 ±Inf 也视为解析失败。
@@ -377,3 +422,111 @@ func medianFloat(xs []float64) float64 {
}
return (xs[n/2-1] + xs[n/2]) / 2
}
// sma 计算切片末尾 period 个值的简单移动均值。
// len < period 返回 ok=false。
func sma(values []float64, period int) (float64, bool) {
n := len(values)
if period <= 0 || n < period {
return 0, false
}
sum := 0.0
for _, v := range values[n-period:] {
sum += v
}
return sum / float64(period), true
}
// stddevPop 计算切片末尾 period 个值相对 mean 的总体标准差(除以 N
// 金融惯例TradingView / TA-Lib布林带用 population stddev不用 sample。
func stddevPop(values []float64, period int, mean float64) (float64, bool) {
n := len(values)
if period <= 0 || n < period {
return 0, false
}
sumSq := 0.0
for _, v := range values[n-period:] {
d := v - mean
sumSq += d * d
}
return math.Sqrt(sumSq / float64(period)), true
}
// emaLast 计算时间序列末尾的 EMA(period)。
// 初值用前 period 根的 SMA标准做法之后用 α=2/(period+1) 递推。
// len < period 返回 ok=false。
func emaLast(values []float64, period int) (float64, bool) {
n := len(values)
if period <= 0 || n < period {
return 0, false
}
seed := 0.0
for _, v := range values[:period] {
seed += v
}
ema := seed / float64(period)
if n == period {
return ema, true
}
alpha := 2.0 / float64(period+1)
for _, v := range values[period:] {
ema = alpha*v + (1-alpha)*ema
}
return ema, true
}
// bollinger 在 closes 上算 20 周期、2σ 布林带。
// 数据不足 bbPeriod 根或 mid 为 0 时返回 nil。
// Squeeze 按 BandwidthPct百分比分档< 4 tight> 12 expanded否则 normal。
func bollinger(closes []float64) *entity.Bollinger {
mid, ok := sma(closes, bbPeriod)
if !ok || mid == 0 {
return nil
}
sd, ok := stddevPop(closes, bbPeriod, mid)
if !ok {
return nil
}
upper := mid + bbStdMult*sd
lower := mid - bbStdMult*sd
bw := (upper - lower) / mid * 100
squeeze := "normal"
switch {
case bw < bbSqueezeTight:
squeeze = "tight"
case bw > bbSqueezeExpanded:
squeeze = "expanded"
}
return &entity.Bollinger{
Mid: formatPrice(mid),
Upper: formatPrice(upper),
Lower: formatPrice(lower),
BandwidthPct: formatPrice(bw),
Squeeze: squeeze,
}
}
// vegas 在 closes 上算 EMA12/144/169 + 趋势判定。
// 任一 EMA 计算失败(长度不足 169则返回 nil。
// Trendfast > mid > slow → bullfast < mid < slow → bear其余 range。
func vegas(closes []float64) *entity.Vegas {
fast, ok1 := emaLast(closes, vegasFast)
mid, ok2 := emaLast(closes, vegasMid)
slow, ok3 := emaLast(closes, vegasSlow)
if !ok1 || !ok2 || !ok3 {
return nil
}
trend := "range"
switch {
case fast > mid && mid > slow:
trend = "bull"
case fast < mid && mid < slow:
trend = "bear"
}
return &entity.Vegas{
EMA12: formatPrice(fast),
EMA144: formatPrice(mid),
EMA169: formatPrice(slow),
Trend: trend,
}
}

View File

@@ -491,7 +491,7 @@ func TestNearestKlineIndex(t *testing.T) {
func TestCompute_EmptyInputs(t *testing.T) {
u := NewIndicatorUsecase()
out := u.Compute(nil, nil)
out := u.Compute(nil, "1h", nil)
require.NotNil(t, out.Support)
require.NotNil(t, out.Resistance)
require.Empty(t, out.Support)
@@ -499,6 +499,8 @@ func TestCompute_EmptyInputs(t *testing.T) {
require.Nil(t, out.RangeHigh)
require.Nil(t, out.RangeLow)
require.Nil(t, out.LongShortLine)
require.NotNil(t, out.Intervals)
require.Empty(t, out.Intervals)
}
func TestCompute_SmokeWithVShape(t *testing.T) {
@@ -519,10 +521,113 @@ func TestCompute_SmokeWithVShape(t *testing.T) {
strconv.FormatFloat(p+0.25, 'f', -1, 64),
)
}
out := u.Compute(klines, nil)
out := u.Compute(map[string][]entity.Kline{"1h": klines}, "1h", 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")
require.Contains(t, out.Intervals, "1h")
}
// ---- Bollinger / Vegas -------------------------------------------------
// flatCloses 生成 n 根价位恒为 v 的 closes用于 Squeeze=tight 验证)。
func flatCloses(n int, v float64) []float64 {
out := make([]float64, n)
for i := range out {
out[i] = v
}
return out
}
// ascCloses 生成等差递增 closesbase, base+step, ...
func ascCloses(n int, base, step float64) []float64 {
out := make([]float64, n)
for i := range out {
out[i] = base + float64(i)*step
}
return out
}
func TestBollinger_FlatSeries_Tight(t *testing.T) {
bb := bollinger(flatCloses(20, 100))
require.NotNil(t, bb)
require.Equal(t, "100", bb.Mid)
require.Equal(t, "100", bb.Upper)
require.Equal(t, "100", bb.Lower)
require.Equal(t, "0", bb.BandwidthPct)
require.Equal(t, "tight", bb.Squeeze)
}
func TestBollinger_AscendingSeries_Expanded(t *testing.T) {
// 0..29 等差,末 20 根 mid=19.5、σ ≈ 5.77、bw ≈ 118%
bb := bollinger(ascCloses(30, 0, 1))
require.NotNil(t, bb)
require.Equal(t, "expanded", bb.Squeeze)
mid, _ := strconv.ParseFloat(bb.Mid, 64)
require.InDelta(t, 19.5, mid, 1e-9)
}
func TestBollinger_InsufficientData(t *testing.T) {
require.Nil(t, bollinger(flatCloses(19, 100)))
require.Nil(t, bollinger(nil))
}
func TestVegas_BullishStack(t *testing.T) {
// 上升序列 → EMA12 > EMA144 > EMA169趋势 bull
v := vegas(ascCloses(200, 100, 1))
require.NotNil(t, v)
e12, _ := strconv.ParseFloat(v.EMA12, 64)
e144, _ := strconv.ParseFloat(v.EMA144, 64)
e169, _ := strconv.ParseFloat(v.EMA169, 64)
require.Greater(t, e12, e144)
require.Greater(t, e144, e169)
require.Equal(t, "bull", v.Trend)
}
func TestVegas_BearishStack(t *testing.T) {
// 下降序列
v := vegas(ascCloses(200, 300, -1))
require.NotNil(t, v)
require.Equal(t, "bear", v.Trend)
}
func TestVegas_FlatRange(t *testing.T) {
// 平盘:三条线相等
v := vegas(flatCloses(200, 100))
require.NotNil(t, v)
require.Equal(t, "range", v.Trend)
}
func TestVegas_InsufficientData(t *testing.T) {
require.Nil(t, vegas(ascCloses(168, 100, 1)))
}
func TestCompute_IntervalsPopulated(t *testing.T) {
u := NewIndicatorUsecase()
// 给 1h25 根) 与 4h200 根) 喂上升序列
mk := func(n int) []entity.Kline {
out := make([]entity.Kline, n)
for i := 0; i < n; i++ {
p := 100 + float64(i)
out[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),
)
}
return out
}
out := u.Compute(map[string][]entity.Kline{
"1h": mk(25),
"4h": mk(200),
}, "1h", nil)
require.Contains(t, out.Intervals, "1h")
require.Contains(t, out.Intervals, "4h")
require.NotNil(t, out.Intervals["1h"].Bollinger, "25 closes 够算 BB")
require.Nil(t, out.Intervals["1h"].Vegas, "25 closes 不够算 EMA169")
require.NotNil(t, out.Intervals["4h"].Bollinger)
require.NotNil(t, out.Intervals["4h"].Vegas)
}

View File

@@ -16,6 +16,9 @@ import (
var supportedSymbols = map[string]bool{
"BTCUSDT": true,
"ETHUSDT": true,
"SOLUSDT": true,
"BNBUSDT": true,
"DOGEUSDT": true,
}
var supportedIntervals = []string{"15m", "1h", "4h", "1d", "1w"}
@@ -40,6 +43,7 @@ type MarketContextUsecase struct {
lsRepo LongShortRatioRepository
indicator IndicatorComputer
derivSignal DerivativesSignalComputer
log *slog.Logger
}
@@ -52,6 +56,7 @@ func NewMarketContextUsecase(
oiRepo OpenInterestRepository,
lsRepo LongShortRatioRepository,
indicator IndicatorComputer,
derivSignal DerivativesSignalComputer,
log *slog.Logger,
) *MarketContextUsecase {
return &MarketContextUsecase{
@@ -62,6 +67,7 @@ func NewMarketContextUsecase(
oiRepo: oiRepo,
lsRepo: lsRepo,
indicator: indicator,
derivSignal: derivSignal,
log: log,
}
}
@@ -167,12 +173,7 @@ func (u *MarketContextUsecase) Build(ctx context.Context, symbol string) (*entit
addWarn("top trader position long/short query failed: " + err.Error())
}
out := &entity.MarketContext{
Symbol: symbol,
GeneratedAt: time.Now().UnixMilli(),
Snapshot: snapshot,
Klines: klines,
Derivatives: entity.DerivativesBundle{
derivBundle := entity.DerivativesBundle{
Funding: entity.FundingBundle{
Current: currentFund,
History: fundingHist,
@@ -186,8 +187,22 @@ func (u *MarketContextUsecase) Build(ctx context.Context, symbol string) (*entit
TopTraderPosition: topLS,
},
TakerBuySellVolume: nil,
},
Technical: u.indicator.Compute(klines[derivativePeriod], globalLS),
}
if u.derivSignal != nil {
signal, sigWarnings := u.derivSignal.Compute(currentFund, oiHist, globalLS, klines[derivativePeriod])
derivBundle.Signal = signal
for _, w := range sigWarnings {
addWarn(w)
}
}
out := &entity.MarketContext{
Symbol: symbol,
GeneratedAt: time.Now().UnixMilli(),
Snapshot: snapshot,
Klines: klines,
Derivatives: derivBundle,
Technical: u.indicator.Compute(klines, derivativePeriod, globalLS),
DataQuality: entity.DataQuality{
Source: "binance",
Warnings: warnings,

View File

@@ -58,7 +58,34 @@ type TakerVolumeRepository interface {
// 过 kline 与 long-short ratio复用结果即可二次注入仓库引用会
// 重复 IO。
//
// klinesByInterval 是按周期分组的 K 线 map每周期一份升序切片
// primaryInterval 指定哪个周期参与 support/resistance/range/longShortLine
// 的计算(其它周期只参与 Intervals 多周期指标如 Bollinger / Vegas
// 实现可见 internal/usecase/indicator.go。
type IndicatorComputer interface {
Compute(klines []entity.Kline, longShort []entity.LongShortRatio) entity.TechnicalStructure
Compute(
klinesByInterval map[string][]entity.Kline,
primaryInterval string,
longShort []entity.LongShortRatio,
) entity.TechnicalStructure
}
// DerivativesSignalComputer 把 funding/OI/LSR 的原始数据翻译成
// 可读语义crowded_long、long_building 之类)。
//
// 与 IndicatorComputer 一样是纯函数式接口、零 repo 依赖。
//
// primaryKlines 是用来给 OI 信号做"价格方向"判定的OI 在涨时,配合
// 价格涨 → long_building价格跌 → short_buildingfunding 不参与 OI
// 判定,避免价格下跌但 funding 仍为正时被误判)。
//
// 返回 warnings 用来告诉调用方"哪一项数据不够、对应字段为什么留空"
// 由调用方合并到 DataQuality.Warnings。signal 整体不可判时返回 nil。
type DerivativesSignalComputer interface {
Compute(
currentFunding *entity.FundingRate,
oiHistory []entity.OpenInterest,
globalLSR []entity.LongShortRatio,
primaryKlines []entity.Kline,
) (*entity.DerivativesSignal, []string)
}