feat: 自动候选筛选与股票研究工作台
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ScreeningRepository interface {
|
||||
Targets(context.Context, model.ProviderConfig) ([]model.ScreeningTarget, error)
|
||||
Record(context.Context, model.ScreeningTarget, model.StrategyScreening, *model.CandidateDraftInput) error
|
||||
List(context.Context) ([]model.StrategyScreening, error)
|
||||
Detail(context.Context, string) (model.StrategyScreening, error)
|
||||
}
|
||||
type ScreeningService struct {
|
||||
repo ScreeningRepository
|
||||
market *MarketService
|
||||
development bool
|
||||
cursor int
|
||||
mu sync.Mutex
|
||||
wake chan struct{}
|
||||
state model.ScreeningWorkerState
|
||||
}
|
||||
|
||||
func NewScreeningService(repo ScreeningRepository, market *MarketService, development bool) *ScreeningService {
|
||||
return &ScreeningService{repo: repo, market: market, development: development, wake: make(chan struct{}, 1), state: model.ScreeningWorkerState{State: "IDLE", Message: "尚未运行"}}
|
||||
}
|
||||
func (s *ScreeningService) List(ctx context.Context, actor model.User) ([]model.StrategyScreening, error) {
|
||||
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
|
||||
return nil, apperror.ErrForbidden
|
||||
}
|
||||
if s == nil || s.repo == nil || !s.development {
|
||||
return nil, apperror.ErrUnavailable
|
||||
}
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
func (s *ScreeningService) Detail(ctx context.Context, actor model.User, id string) (model.StrategyScreening, error) {
|
||||
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
|
||||
return model.StrategyScreening{}, apperror.ErrForbidden
|
||||
}
|
||||
if s == nil || s.repo == nil || !s.development {
|
||||
return model.StrategyScreening{}, apperror.ErrUnavailable
|
||||
}
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return model.StrategyScreening{}, apperror.ErrInvalidInput
|
||||
}
|
||||
return s.repo.Detail(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ScreeningService) State(actor model.User) (model.ScreeningWorkerState, error) {
|
||||
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
|
||||
return model.ScreeningWorkerState{}, apperror.ErrForbidden
|
||||
}
|
||||
if s == nil || s.repo == nil || s.market == nil || s.market.providers == nil || s.market.adapter == nil || !s.development {
|
||||
return model.ScreeningWorkerState{}, apperror.ErrUnavailable
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state, nil
|
||||
}
|
||||
func (s *ScreeningService) RequestRun(actor model.User) (model.ScreeningWorkerState, error) {
|
||||
if _, err := s.State(actor); err != nil {
|
||||
return model.ScreeningWorkerState{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.State == "RUNNING" || s.state.State == "QUEUED" {
|
||||
return s.state, nil
|
||||
}
|
||||
s.state = model.ScreeningWorkerState{State: "QUEUED", Message: "已排队,等待后台开始筛选"}
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return s.state, nil
|
||||
}
|
||||
|
||||
var screeningZone = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// EvaluateScreening uses completed daily bars only. Re-fetching a historical quote
|
||||
// never makes it fresh, and an unclosed session cannot generate a daily signal.
|
||||
func EvaluateScreening(t model.ScreeningTarget, c model.ProviderConfig, h model.MarketHistory, now time.Time) (model.StrategyScreening, *model.CandidateDraftInput, error) {
|
||||
var result model.StrategyScreening
|
||||
p := t.Version.Parameters
|
||||
q := p.Screening
|
||||
i := t.Instrument
|
||||
if q == nil || model.ValidateScreening(q) != nil || p.ShortWindow < 2 || p.LongWindow <= p.ShortWindow || p.LongWindow > 200 || h.Symbol != i.Symbol || h.Exchange != i.Exchange || h.Source != c.MarketAdapter || h.SourceVersion != c.Version || h.Adjustment != "unadjusted" || i.SourceID != c.ID || i.SourceVersion != c.Version || i.ObservedAt.Before(now.Add(-24*time.Hour)) || i.ObservedAt.After(now.Add(time.Minute)) || i.ReceivedAt.Before(now.Add(-10*time.Minute)) || h.ReceivedAt.Before(now.Add(-10*time.Minute)) || h.ReceivedAt.After(now.Add(time.Minute)) {
|
||||
return result, nil, apperror.ErrUnavailable
|
||||
}
|
||||
day := i.ObservedAt.In(screeningZone).Format("2006-01-02")
|
||||
localNow := now.In(screeningZone)
|
||||
if day == localNow.Format("2006-01-02") && localNow.Hour() < 16 {
|
||||
return result, nil, apperror.ErrUnavailable
|
||||
}
|
||||
if len(h.Bars) < p.LongWindow+1 || h.Bars[len(h.Bars)-1].Date != day {
|
||||
return result, nil, apperror.ErrUnavailable
|
||||
}
|
||||
bars := append([]model.HistoricalBar(nil), h.Bars[len(h.Bars)-p.LongWindow-1:]...)
|
||||
closes := make([]int64, len(bars))
|
||||
previous := ""
|
||||
for j, b := range bars {
|
||||
d, e := time.Parse("2006-01-02", b.Date)
|
||||
o, e1 := model.ParseMoney(b.Open)
|
||||
hi, e2 := model.ParseMoney(b.High)
|
||||
lo, e3 := model.ParseMoney(b.Low)
|
||||
cl, e4 := model.ParseMoney(b.Close)
|
||||
if e != nil || d.Format("2006-01-02") != b.Date || b.Date <= previous || b.Date > day || e1 != nil || e2 != nil || e3 != nil || e4 != nil || lo <= 0 || hi < lo || o < lo || o > hi || cl < lo || cl > hi {
|
||||
return result, nil, apperror.ErrInvalidInput
|
||||
}
|
||||
closes[j] = cl
|
||||
previous = b.Date
|
||||
}
|
||||
sum := func(end, n int) int64 {
|
||||
var v int64
|
||||
for _, x := range closes[end-n : end] {
|
||||
v += x
|
||||
}
|
||||
return v
|
||||
}
|
||||
end := len(closes)
|
||||
prevShort, prevLong := sum(end-1, p.ShortWindow), sum(end-1, p.LongWindow)
|
||||
short, long := sum(end, p.ShortWindow), sum(end, p.LongWindow)
|
||||
signal := prevShort*int64(p.LongWindow) <= prevLong*int64(p.ShortWindow) && short*int64(p.LongWindow) > long*int64(p.ShortWindow)
|
||||
input, _ := json.Marshal(struct {
|
||||
Parameters model.StrategyParameters `json:"parameters"`
|
||||
Bars []model.HistoricalBar `json:"bars"`
|
||||
}{p, bars})
|
||||
hash := sha256.Sum256(input)
|
||||
result = model.StrategyScreening{StrategyVersionID: t.Version.ID, Symbol: i.Symbol, Exchange: i.Exchange, BarDate: day, SourceID: c.ID, SourceVersion: c.Version, SourceAdapter: c.MarketAdapter, ObservedAt: i.ObservedAt, ReceivedAt: h.ReceivedAt, InputHash: hex.EncodeToString(hash[:]), Input: input, Status: "NO_SIGNAL"}
|
||||
if !signal {
|
||||
return result, nil, nil
|
||||
}
|
||||
base := closes[end-1]
|
||||
low := base * (10000 - q.EntryBandBPS) / 10000
|
||||
high := (base*(10000+q.EntryBandBPS) + 9999) / 10000
|
||||
stop := base * (10000 - q.StopLossBPS) / 10000
|
||||
target := (base*(10000+q.TargetBPS) + 9999) / 10000
|
||||
draft := model.CandidateDraftInput{ObservedAt: i.ObservedAt, ValidUntil: now.Add(time.Duration(q.ValidHours) * time.Hour), Content: model.CandidateContent{
|
||||
Symbol: i.Symbol, Exchange: i.Exchange, Name: i.Name, StrategyCode: "trend_following", Title: i.Name + " · 日线均线交叉待审核",
|
||||
Thesis: fmt.Sprintf("已完成日线 %s 出现 %d / %d 日均线上穿。策略 %s 修订 %d,仅为规则筛选结果,需人工核验后发布。", day, p.ShortWindow, p.LongWindow, t.StrategyName, t.Version.Revision),
|
||||
Evidence: []model.CandidateEvidence{{SourceURL: c.HistoryBaseURL, Locator: fmt.Sprintf("%s:%s 日线 %s", i.Exchange, i.Symbol, day), Summary: fmt.Sprintf("来源 %s 配置版本 %d;未复权收盘价 %s;输入摘要 %s。参数与日线快照在筛选记录中保留。", c.MarketAdapter, c.Version, model.FormatMoney(base), result.InputHash)}},
|
||||
CounterEvidence: "公开数据可能缺失或延迟;未复权均线会受除权影响。此规则未核验公告、停复牌、涨跌停、流动性及策略有效性。",
|
||||
Invalidation: "策略暂停或切换版本、候选过期或撤回后失效;复核价格区间及数据来源后方可发布。",
|
||||
ReferencePlan: model.CandidateReferencePlan{EntryLow: model.FormatMoney(low), EntryHigh: model.FormatMoney(high), StopLoss: model.FormatMoney(stop), TargetPrice: model.FormatMoney(target)},
|
||||
}}
|
||||
if e := model.ValidateDraft(draft, now); e != nil {
|
||||
return result, nil, e
|
||||
}
|
||||
result.Status = "SIGNAL"
|
||||
return result, &draft, nil
|
||||
}
|
||||
func (s *ScreeningService) Process(ctx context.Context, logger *zap.Logger) (processErr error) {
|
||||
started := time.Now().UTC()
|
||||
s.mu.Lock()
|
||||
if s.state.State == "RUNNING" {
|
||||
s.mu.Unlock()
|
||||
return apperror.ErrConflict
|
||||
}
|
||||
s.state = model.ScreeningWorkerState{State: "RUNNING", StartedAt: &started, Message: "正在获取日线并计算信号"}
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
finished := time.Now().UTC()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.state.FinishedAt = &finished
|
||||
if processErr != nil {
|
||||
s.state.State = "FAILED"
|
||||
s.state.Message = "筛选未完成,请检查行情源与后台日志"
|
||||
} else {
|
||||
s.state.State = "COMPLETED"
|
||||
s.state.Message = fmt.Sprintf("检查 %d 只,完成 %d 只,生成 %d 条待审核候选,跳过 %d 只,失败 %d 只", s.state.Targets, s.state.Completed, s.state.Generated, s.state.Skipped, s.state.Failed)
|
||||
}
|
||||
}()
|
||||
|
||||
c, err := s.market.configuration(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := s.repo.Targets(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Rotate across eligible targets so an unavailable feed cannot starve other strategies.
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
start := s.cursor % len(targets)
|
||||
count := min(20, len(targets))
|
||||
s.state.Targets = count
|
||||
s.cursor = (start + count) % len(targets)
|
||||
s.mu.Unlock()
|
||||
for offset := 0; offset < count; offset++ {
|
||||
t := targets[(start+offset)%len(targets)]
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
task, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
h, e := s.market.adapter.History(task, c, t.Instrument.Symbol, t.Instrument.Exchange, now.AddDate(-3, 0, 0).Format("2006-01-02"), now.Format("2006-01-02"))
|
||||
if e == nil {
|
||||
var run model.StrategyScreening
|
||||
var draft *model.CandidateDraftInput
|
||||
run, draft, e = EvaluateScreening(t, c, h, time.Now().UTC())
|
||||
if e == nil {
|
||||
e = s.repo.Record(task, t, run, draft)
|
||||
if e == nil {
|
||||
s.mu.Lock()
|
||||
s.state.Completed++
|
||||
if draft != nil {
|
||||
s.state.Generated++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if e != nil {
|
||||
s.mu.Lock()
|
||||
if errors.Is(e, apperror.ErrConflict) || errors.Is(e, apperror.ErrUnavailable) {
|
||||
s.state.Skipped++
|
||||
} else {
|
||||
s.state.Failed++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
if e != nil && !errors.Is(e, apperror.ErrConflict) && !errors.Is(e, apperror.ErrUnavailable) && ctx.Err() == nil {
|
||||
logger.Warn("strategy screening failed", zap.String("strategy_version_id", t.Version.ID), zap.String("symbol", t.Instrument.Symbol), zap.Error(e))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *ScreeningService) Run(ctx context.Context, logger *zap.Logger) {
|
||||
if s == nil || s.repo == nil || s.market == nil || s.market.adapter == nil || !s.development {
|
||||
return
|
||||
}
|
||||
for {
|
||||
if err := s.Process(ctx, logger); err != nil && !errors.Is(err, apperror.ErrUnavailable) && !errors.Is(err, apperror.ErrNotFound) && ctx.Err() == nil {
|
||||
logger.Warn("strategy screening worker failed", zap.Error(err))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.wake:
|
||||
case <-time.After(5 * time.Minute):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
)
|
||||
|
||||
func screeningFixture() (model.ScreeningTarget, model.ProviderConfig, model.MarketHistory, time.Time) {
|
||||
now := time.Date(2026, 10, 9, 9, 0, 0, 0, time.UTC) // 17:00 Shanghai
|
||||
c := model.ProviderConfig{ID: "source", Version: 2, MarketAdapter: "sina_public", HistoryBaseURL: "https://quotes.example.cn"}
|
||||
p := model.StrategyParameters{ShortWindow: 2, LongWindow: 3, Screening: &model.ScreeningParameters{Securities: []model.ScreeningSecurity{{Symbol: "600000", Exchange: "SSE"}}, EntryBandBPS: 100, StopLossBPS: 500, TargetBPS: 1000, ValidHours: 24}}
|
||||
t := model.ScreeningTarget{StrategyID: "strategy", StrategyName: "均线研究", Version: model.StrategyVersion{ID: "version", Revision: 3, Parameters: p}, Instrument: model.Instrument{Symbol: "600000", Exchange: "SSE", Name: "fixture", SourceID: c.ID, SourceVersion: c.Version, ObservedAt: now.Add(-2 * time.Hour), ReceivedAt: now}}
|
||||
h := model.MarketHistory{Symbol: "600000", Exchange: "SSE", Source: c.MarketAdapter, SourceVersion: c.Version, Adjustment: "unadjusted", ReceivedAt: now, Bars: []model.HistoricalBar{{Date: "2026-10-06", Open: "10.00", High: "10.00", Low: "10.00", Close: "10.00"}, {Date: "2026-10-07", Open: "9.00", High: "9.00", Low: "9.00", Close: "9.00"}, {Date: "2026-10-08", Open: "8.00", High: "8.00", Low: "8.00", Close: "8.00"}, {Date: "2026-10-09", Open: "20.01", High: "20.01", Low: "20.01", Close: "20.01"}}}
|
||||
return t, c, h, now
|
||||
}
|
||||
func TestScreeningExactCompletedBarSignal(t *testing.T) {
|
||||
target, c, h, now := screeningFixture()
|
||||
run, draft, e := EvaluateScreening(target, c, h, now)
|
||||
if e != nil || draft == nil || run.Status != "SIGNAL" || len(run.InputHash) != 64 {
|
||||
t.Fatalf("signal: %v %#v", e, run)
|
||||
}
|
||||
plan := draft.Content.ReferencePlan
|
||||
if plan.EntryLow != "19.80" || plan.EntryHigh != "20.22" || plan.StopLoss != "19.00" || plan.TargetPrice != "22.02" {
|
||||
t.Fatalf("price precision: %+v", plan)
|
||||
}
|
||||
again, _, _ := EvaluateScreening(target, c, h, now.Add(time.Minute))
|
||||
if again.InputHash != run.InputHash {
|
||||
t.Fatal("hash depends on receive time")
|
||||
}
|
||||
h.Bars[3] = model.HistoricalBar{Date: "2026-10-09", Open: "7", High: "7", Low: "7", Close: "7"}
|
||||
run, draft, e = EvaluateScreening(target, c, h, now)
|
||||
if e != nil || draft != nil || run.Status != "NO_SIGNAL" {
|
||||
t.Fatal("falling average manufactured signal")
|
||||
}
|
||||
}
|
||||
func TestScreeningRejectsUnusableInputs(t *testing.T) {
|
||||
for _, name := range []string{"intraday", "stale_observation", "stale_receive", "wrong_source", "missing_bar", "duplicate_bar", "invalid_ohlc", "invalid_rules", "future_receive"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
target, c, h, now := screeningFixture()
|
||||
switch name {
|
||||
case "intraday":
|
||||
now = now.Add(-2 * time.Hour)
|
||||
target.Instrument.ReceivedAt = now
|
||||
h.ReceivedAt = now
|
||||
case "stale_observation":
|
||||
target.Instrument.ObservedAt = now.Add(-25 * time.Hour)
|
||||
case "stale_receive":
|
||||
target.Instrument.ReceivedAt = now.Add(-11 * time.Minute)
|
||||
case "wrong_source":
|
||||
h.SourceVersion++
|
||||
case "missing_bar":
|
||||
h.Bars = h.Bars[:3]
|
||||
case "duplicate_bar":
|
||||
h.Bars[1].Date = h.Bars[0].Date
|
||||
case "invalid_ohlc":
|
||||
h.Bars[3].High = "19"
|
||||
case "invalid_rules":
|
||||
target.Version.Parameters.Screening.StopLossBPS = 100
|
||||
case "future_receive":
|
||||
h.ReceivedAt = now.Add(2 * time.Minute)
|
||||
}
|
||||
if _, draft, e := EvaluateScreening(target, c, h, now); e == nil || draft != nil {
|
||||
t.Fatal("unsafe data generated candidate")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,9 @@ func (s *StrategyService) Save(ctx context.Context, actor model.User, id string,
|
||||
if !strategyCodePattern.MatchString(input.Code) || input.Name == "" || utf8.RuneCountInString(input.Name) > 80 || input.Description == "" || utf8.RuneCountInString(input.Description) > 2000 || p.ShortWindow < 2 || p.LongWindow <= p.ShortWindow || p.LongWindow > 200 || p.FeeBPS < 0 || p.FeeBPS > 1000 || p.SlippageBPS < 0 || p.SlippageBPS > 1000 || p.LotSize < 1 || p.LotSize > 10000 {
|
||||
return model.StrategyDetail{}, apperror.ErrInvalidInput
|
||||
}
|
||||
if err := model.ValidateScreening(p.Screening); err != nil {
|
||||
return model.StrategyDetail{}, err
|
||||
}
|
||||
return s.repo.Save(ctx, id, input, actor.ID, requestID)
|
||||
}
|
||||
func (s *StrategyService) Deploy(ctx context.Context, actor model.User, id, versionID, status string, expected int64, reason, requestID string) (model.StrategyDetail, error) {
|
||||
|
||||
Reference in New Issue
Block a user