package integration import ( "context" "encoding/json" "errors" "go.uber.org/zap" "strings" "sync" "testing" "time" "git.sundynix.cn/Blizzard/Veyra/internal/apperror" "git.sundynix.cn/Blizzard/Veyra/internal/model" "git.sundynix.cn/Blizzard/Veyra/internal/repository" "git.sundynix.cn/Blizzard/Veyra/internal/service" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func testScreeningLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Pool, admin, user model.User) { strategies := service.NewStrategyService(repository.NewStrategyRepository(pool), true) input := service.StrategyInput{Code: "auto_screening", Name: "自动筛选测试", Description: "integration fixture", Parameters: model.StrategyParameters{ShortWindow: 2, LongWindow: 3, LotSize: 100, Screening: &model.ScreeningParameters{Securities: []model.ScreeningSecurity{{Symbol: "600000", Exchange: "SSE"}}, EntryBandBPS: 100, StopLossBPS: 500, TargetBPS: 1000, ValidHours: 24}}} st, err := strategies.Save(ctx, admin, "", input, uuid.NewString()) if err != nil { t.Fatal(err) } st, err = strategies.Deploy(ctx, admin, st.ID, st.Versions[0].ID, "ACTIVE", st.Version, "fixture activation", uuid.NewString()) if err != nil { t.Fatal(err) } providerRepo := repository.NewProviderRepository(pool) provider, err := providerRepo.Get(ctx, "market_primary") if err != nil { t.Fatal(err) } // Only disposable-schema fixtures are modified, never the development configuration. if _, err := pool.Exec(ctx, `UPDATE veyra_provider_config SET enabled=true,market_adapter='sina_public',history_base_url='https://quotes.example.cn' WHERE id=$1`, provider.ID); err != nil { t.Fatal(err) } provider, err = providerRepo.Get(ctx, "market_primary") if err != nil { t.Fatal(err) } observed := time.Now().UTC().Add(-23 * time.Hour) instrument := model.Instrument{Symbol: "600000", Exchange: "SSE", Name: "fixture", PriceCents: 2001, ObservedAt: observed} if err := repository.NewMarketRepository(pool).Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil { t.Fatal(err) } searchRepo := repository.NewMarketRepository(pool) if err := searchRepo.Save(ctx, provider, []model.Instrument{{Symbol: "002215", Exchange: "SZSE", Name: "诺 普 信", PriceCents: 1000, ObservedAt: observed}}, time.Now().UTC(), ""); err != nil { t.Fatal(err) } for _, query := range []string{"诺普信", "诺 普 信", " 诺\t普 信 ", "002215", "002 215"} { stocks, err := searchRepo.List(ctx, query) if err != nil || len(stocks) != 1 || stocks[0].Symbol != "002215" || stocks[0].Name != "诺 普 信" { t.Fatalf("padded security search %q: %v, %v", query, stocks, err) } } for _, query := range []string{"not-a-security", "%", "_"} { stocks, err := searchRepo.List(ctx, query) if err != nil || len(stocks) != 0 { t.Fatalf("literal security search %q matched: %v, %v", query, stocks, err) } } repo := repository.NewScreeningRepository(pool) targets, err := repo.Targets(ctx, provider) if err != nil || len(targets) != 1 { t.Fatalf("targets %d: %v", len(targets), err) } target := targets[0] zone := time.FixedZone("Shanghai", 8*3600) run := model.StrategyScreening{StrategyVersionID: target.Version.ID, Symbol: "600000", Exchange: "SSE", BarDate: observed.In(zone).Format("2006-01-02"), SourceID: provider.ID, SourceVersion: provider.Version, SourceAdapter: "sina_public", ObservedAt: observed, ReceivedAt: time.Now().UTC(), InputHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Input: json.RawMessage(`{"fixture":true}`), Status: "SIGNAL"} draft := model.CandidateDraftInput{ObservedAt: observed, ValidUntil: time.Now().UTC().Add(time.Hour), Content: model.CandidateContent{Symbol: "600000", Exchange: "SSE", Name: "fixture", StrategyCode: "trend_following", Title: "fixture signal", Thesis: "fixture", CounterEvidence: "fixture risk", Invalidation: "fixture pause", Evidence: []model.CandidateEvidence{{SourceURL: "https://quotes.example.cn", Locator: "fixture", Summary: "fixture"}}, ReferencePlan: model.CandidateReferencePlan{EntryLow: "19.80", EntryHigh: "20.22", StopLoss: "19.00", TargetPrice: "22.02"}}} var before int if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_candidate`).Scan(&before); err != nil { t.Fatal(err) } var wg sync.WaitGroup results := make(chan error, 2) for range 2 { wg.Add(1) go func() { defer wg.Done(); results <- repo.Record(ctx, target, run, &draft) }() } wg.Wait() close(results) success, conflict := 0, 0 for e := range results { if e == nil { success++ } else if errors.Is(e, apperror.ErrConflict) { conflict++ } else { t.Fatal(e) } } if success != 1 || conflict != 1 { t.Fatal("concurrent duplicate screening did not converge") } var after int if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_candidate`).Scan(&after); err != nil || after != before+1 { t.Fatal("duplicate screening left orphan candidate") } records, err := repo.List(ctx) if err != nil || len(records) != 1 || records[0].CandidateRevisionID == nil { t.Fatal("screening provenance missing") } if _, err := pool.Exec(ctx, `UPDATE veyra_strategy_screening SET status='NO_SIGNAL' WHERE id=$1`, records[0].ID); err == nil { t.Fatal("screening evidence mutable") } recordDetail, err := repo.Detail(ctx, records[0].ID) if err != nil || string(recordDetail.Input) != "{\"fixture\": true}" { t.Fatalf("immutable input detail: %s %v", recordDetail.Input, err) } candidates := service.NewCandidateService(repository.NewCandidateRepository(pool), true) candidate, err := candidates.Detail(ctx, admin, *records[0].CandidateRevisionID, true) if err != nil || candidate.Status != "PENDING_REVIEW" || candidate.Screening == nil { t.Fatalf("review queue provenance: %v", err) } if _, err := candidates.Detail(ctx, user, candidate.ID, false); !errors.Is(err, apperror.ErrNotFound) { t.Fatal("unreviewed signal leaked to App") } pub, err := candidates.Transition(ctx, admin, candidate.ID, "PUBLISH", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: candidate.Version, Reason: "fixture review"}) if err != nil || !pub.IsActivePublication { t.Fatalf("review publish: %v", err) } accounts := service.NewAccountService(repository.NewAccountRepository(pool)) cash := "100000.00" auth, err := service.NewAuthService(repository.NewIdentityRepository(pool)) if err != nil { t.Fatal(err) } owner, err := auth.ProvisionUser(ctx, "scanner_user", "a-test-only-password", "Scanner User", "USER") if err != nil { t.Fatal(err) } account, err := accounts.Create(ctx, owner.ID, "simulation", "scanner fixture", &cash) if err != nil { t.Fatal(err) } plans := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true) preview, err := plans.Preview(ctx, owner, service.PlanPreviewInput{AccountID: account.ID, CandidateRevisionID: pub.ID, ExpectedCandidateVersion: pub.Version, Quantity: "100", FeeBudget: "0"}) if err != nil { t.Fatal(err) } plan, err := plans.Confirm(ctx, owner, preview.ID, uuid.NewString()) if err != nil { t.Fatal(err) } // Real public quote ingestion uses the existing outbox and never writes ledger facts. if err := repository.NewMarketRepository(pool).Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil { t.Fatal(err) } var publicCount int if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_quote_snapshot WHERE data_mode='public'`).Scan(&publicCount); err != nil || publicCount != 0 { t.Fatal("old market close queued a public alert") } instrument.ObservedAt = time.Now().UTC() marketRepo := repository.NewMarketRepository(pool) if err := marketRepo.Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil { t.Fatal(err) } notifications := repository.NewNotificationRepository(pool) for range 100 { worked, err := notifications.ProcessNext(ctx) if err != nil { t.Fatal(err) } if !worked { break } } notices, err := notifications.List(ctx, owner.ID) if err != nil || len(notices) != 1 { t.Fatalf("public alert missing: %v count %d", err, len(notices)) } if !strings.Contains(notices[0].Body, "公开行情价格") { t.Fatal("public price mislabeled as manual simulation") } if err := marketRepo.Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil { t.Fatal(err) } for range 100 { worked, err := notifications.ProcessNext(ctx) if err != nil { t.Fatal(err) } if !worked { break } } notices, err = notifications.List(ctx, owner.ID) if err != nil || len(notices) != 1 { t.Fatal("unchanged quote duplicated reminder") } var balance int64 var fills int if err := pool.QueryRow(ctx, `SELECT sum(amount_minor)::bigint FROM veyra_cash_entry WHERE account_id=$1`, account.ID).Scan(&balance); err != nil || balance != 10000000 { t.Fatal("public alert altered cash") } if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_trade_fill WHERE account_id=$1`, account.ID).Scan(&fills); err != nil || fills != 0 { t.Fatal("public alert posted trade") } // A queued observation from a disabled source must not trigger another plan. preview2, err := plans.Preview(ctx, owner, service.PlanPreviewInput{AccountID: account.ID, CandidateRevisionID: pub.ID, ExpectedCandidateVersion: pub.Version, Quantity: "100", FeeBudget: "0"}) if err != nil { t.Fatal(err) } if _, err := plans.Confirm(ctx, owner, preview2.ID, uuid.NewString()); err != nil { t.Fatal(err) } instrument.ObservedAt = time.Now().UTC() if err := marketRepo.Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE veyra_provider_config SET enabled=false WHERE id=$1`, provider.ID); err != nil { t.Fatal(err) } for range 100 { worked, err := notifications.ProcessNext(ctx) if err != nil { t.Fatal(err) } if !worked { break } } notices, err = notifications.List(ctx, owner.ID) if err != nil || len(notices) != 1 { t.Fatal("disabled source generated delayed reminder") } if _, err := pool.Exec(ctx, `UPDATE veyra_provider_config SET enabled=true WHERE id=$1`, provider.ID); err != nil { t.Fatal(err) } st, err = strategies.Deploy(ctx, admin, st.ID, "", "PAUSED", st.Version, "fixture pause", uuid.NewString()) if err != nil { t.Fatal(err) } detail, err := candidates.Detail(ctx, user, pub.ID, false) if err != nil || detail.IsActivePublication { t.Fatal("paused signal stayed actionable") } if _, err := plans.Preview(ctx, owner, service.PlanPreviewInput{AccountID: account.ID, CandidateRevisionID: pub.ID, ExpectedCandidateVersion: pub.Version, Quantity: "100", FeeBudget: "0"}); !errors.Is(err, apperror.ErrCandidateUnavailable) { t.Fatal("paused strategy accepted plan") } list, err := plans.List(ctx, owner) if err != nil || len(list) != 2 { t.Fatalf("pause did not release plan: %v", err) } found := false for _, p := range list { if p.ID == plan.ID { found = true } if p.Status != "INVALIDATED" || p.ReservedMinor != 0 { t.Fatal("paused plan kept reserve") } } if !found { t.Fatal("original plan disappeared") } run.Symbol = "600001" if err := repo.Record(ctx, target, run, &draft); !errors.Is(err, apperror.ErrConflict) { t.Fatal("paused deployment accepted late worker") } worker := service.NewScreeningService(repo, service.NewMarketService(repository.NewMarketRepository(pool), providerRepo, idleScreeningAdapter{}, true), true) if _, err := worker.RequestRun(user); !errors.Is(err, apperror.ErrForbidden) { t.Fatal("app user requested research run") } first, err := worker.RequestRun(admin) if err != nil || first.State != "QUEUED" { t.Fatal("run did not queue") } second, err := worker.RequestRun(admin) if err != nil || second.State != "QUEUED" { t.Fatal("duplicate queue request changed state") } workerCtx, cancel := context.WithCancel(ctx) done := make(chan struct{}) go func() { defer close(done); worker.Run(workerCtx, zap.NewNop()) }() deadline := time.After(3 * time.Second) for { state, err := worker.State(admin) if err != nil { cancel() <-done t.Fatal(err) } if state.State == "COMPLETED" && state.FinishedAt != nil { break } select { case <-deadline: cancel() <-done t.Fatal("queued worker did not complete") case <-time.After(10 * time.Millisecond): } } cancel() <-done } // There are no eligible targets after pause, so this adapter must never be called. type idleScreeningAdapter struct{} func (idleScreeningAdapter) Snapshot(context.Context, model.ProviderConfig) ([]model.Instrument, error) { return nil, apperror.ErrUnavailable } func (idleScreeningAdapter) History(context.Context, model.ProviderConfig, string, string, string, string) (model.MarketHistory, error) { return model.MarketHistory{}, apperror.ErrUnavailable }