package repository import ( "context" "encoding/json" "time" "git.sundynix.cn/Blizzard/Veyra/internal/apperror" "git.sundynix.cn/Blizzard/Veyra/internal/model" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) type ScreeningRepository struct{ pool *pgxpool.Pool } func NewScreeningRepository(pool *pgxpool.Pool) *ScreeningRepository { return &ScreeningRepository{pool} } func (r *ScreeningRepository) Targets(ctx context.Context, c model.ProviderConfig) ([]model.ScreeningTarget, error) { rows, err := r.pool.Query(ctx, `SELECT s.id,s.name,v.id,v.strategy_id,v.revision,v.algorithm,v.parameters,v.description,v.author_id,v.created_at,i.id,i.symbol,i.exchange,i.name,i.price_cents,i.observed_at,i.received_at,i.source_id,i.source_version FROM veyra_strategy s JOIN veyra_strategy_version v ON v.id=s.active_version_id CROSS JOIN LATERAL jsonb_array_elements(COALESCE(v.parameters->'screening'->'securities','[]'::jsonb)) security JOIN veyra_instrument i ON i.symbol=security->>'symbol' AND i.exchange=security->>'exchange' JOIN veyra_user u ON u.id=v.author_id AND u.role='ADMIN' AND u.status='ACTIVE' WHERE s.status='ACTIVE' AND i.source_id=$1 AND i.source_version=$2 AND i.observed_at>=now()-interval '24 hours' AND i.observed_at<=now()+interval '1 minute' AND i.received_at>=now()-interval '10 minutes' AND ((i.observed_at AT TIME ZONE 'Asia/Shanghai')::date < (now() AT TIME ZONE 'Asia/Shanghai')::date OR EXTRACT(hour FROM now() AT TIME ZONE 'Asia/Shanghai')>=16) AND NOT EXISTS(SELECT 1 FROM veyra_strategy_screening x WHERE x.strategy_version_id=v.id AND x.symbol=i.symbol AND x.exchange=i.exchange AND x.bar_date=(i.observed_at AT TIME ZONE 'Asia/Shanghai')::date AND x.source_id=$1 AND x.source_version=$2) ORDER BY s.updated_at,s.id,i.symbol LIMIT 2000`, c.ID, c.Version) if err != nil { return nil, mapError(err) } defer rows.Close() targets := make([]model.ScreeningTarget, 0) for rows.Next() { var t model.ScreeningTarget var raw []byte v := &t.Version i := &t.Instrument if err := rows.Scan(&t.StrategyID, &t.StrategyName, &v.ID, &v.StrategyID, &v.Revision, &v.Algorithm, &raw, &v.Description, &v.AuthorID, &v.CreatedAt, &i.ID, &i.Symbol, &i.Exchange, &i.Name, &i.PriceCents, &i.ObservedAt, &i.ReceivedAt, &i.SourceID, &i.SourceVersion); err != nil { return nil, mapError(err) } if json.Unmarshal(raw, &v.Parameters) != nil { return nil, apperror.ErrUnavailable } targets = append(targets, t) } return targets, mapError(rows.Err()) } func (r *ScreeningRepository) Record(ctx context.Context, t model.ScreeningTarget, run model.StrategyScreening, draft *model.CandidateDraftInput) error { tx, err := r.pool.Begin(ctx) if err != nil { return mapError(err) } defer tx.Rollback(context.Background()) // Lock deployment before creating a candidate; pause/version changes cannot race publication. var status string var active *string if err := tx.QueryRow(ctx, `SELECT status,active_version_id FROM veyra_strategy WHERE id=$1 FOR SHARE`, t.StrategyID).Scan(&status, &active); err != nil { return mapError(err) } if status != "ACTIVE" || active == nil || *active != t.Version.ID { return apperror.ErrConflict } var sourceVersion int64 var enabled bool if err := tx.QueryRow(ctx, `SELECT version,enabled FROM veyra_provider_config WHERE id=$1 FOR SHARE`, run.SourceID).Scan(&sourceVersion, &enabled); err != nil { return mapError(err) } if !enabled || sourceVersion != run.SourceVersion { return apperror.ErrConflict } var actorValid bool if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM veyra_user WHERE id=$1 AND status='ACTIVE' AND role='ADMIN')`, t.Version.AuthorID).Scan(&actorValid); err != nil { return mapError(err) } if !actorValid { return apperror.ErrConflict } requestID := uuid.NewString() var revisionID *string if draft != nil { if err := model.ValidateDraft(*draft, time.Now().UTC()); err != nil { return err } candidate, err := createCandidate(ctx, tx, *draft, t.Version.AuthorID, requestID, "", 0) if err != nil { return err } // Machine submission leaves the approval decision to an administrator. if _, err := tx.Exec(ctx, `UPDATE veyra_candidate_revision SET status='PENDING_REVIEW',version=version+1 WHERE id=$1`, candidate.ID); err != nil { return mapError(err) } if _, err := tx.Exec(ctx, `INSERT INTO veyra_candidate_review(id,revision_id,reviewer_id,decision,input_version,reason) VALUES($1,$2,$3,'SUBMIT',1,'自动日线筛选提交,等待人工审核')`, uuid.NewString(), candidate.ID, t.Version.AuthorID); err != nil { return mapError(err) } revisionID = &candidate.ID } _, err = tx.Exec(ctx, `INSERT INTO veyra_strategy_screening(id,strategy_version_id,symbol,exchange,bar_date,source_id,source_version,source_adapter,observed_at,received_at,input_hash,input,status,candidate_revision_id) VALUES($1,$2,$3,$4,$5::date,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, uuid.NewString(), t.Version.ID, run.Symbol, run.Exchange, run.BarDate, run.SourceID, run.SourceVersion, run.SourceAdapter, run.ObservedAt, run.ReceivedAt, run.InputHash, run.Input, run.Status, revisionID) if err != nil { return mapError(err) } // Unique key rolls back candidate/audit too, including concurrent workers. if err := appendAudit(ctx, tx, t.Version.AuthorID, "STRATEGY_SCREENING_COMPLETED", "strategy_version", t.Version.ID, requestID, map[string]any{"symbol": run.Symbol, "exchange": run.Exchange, "bar_date": run.BarDate, "status": run.Status, "input_hash": run.InputHash, "candidate_revision_id": revisionID}); err != nil { return err } return mapError(tx.Commit(ctx)) } func (r *ScreeningRepository) List(ctx context.Context) ([]model.StrategyScreening, error) { rows, err := r.pool.Query(ctx, `SELECT x.id,x.strategy_version_id,s.name,v.revision,x.symbol,x.exchange,x.bar_date::text,x.source_id,x.source_version,x.source_adapter,x.observed_at,x.received_at,x.input_hash,x.status,x.candidate_revision_id,x.created_at FROM veyra_strategy_screening x JOIN veyra_strategy_version v ON v.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=v.strategy_id ORDER BY x.created_at DESC,x.id DESC LIMIT 100`) if err != nil { return nil, mapError(err) } defer rows.Close() list := make([]model.StrategyScreening, 0) for rows.Next() { var x model.StrategyScreening if err := rows.Scan(&x.ID, &x.StrategyVersionID, &x.StrategyName, &x.StrategyRevision, &x.Symbol, &x.Exchange, &x.BarDate, &x.SourceID, &x.SourceVersion, &x.SourceAdapter, &x.ObservedAt, &x.ReceivedAt, &x.InputHash, &x.Status, &x.CandidateRevisionID, &x.CreatedAt); err != nil { return nil, mapError(err) } list = append(list, x) } return list, mapError(rows.Err()) } func (r *ScreeningRepository) Detail(ctx context.Context, id string) (model.StrategyScreening, error) { var x model.StrategyScreening err := r.pool.QueryRow(ctx, `SELECT x.id,x.strategy_version_id,s.name,v.revision,x.symbol,x.exchange,x.bar_date::text,x.source_id,x.source_version,x.source_adapter,x.observed_at,x.received_at,x.input_hash,x.status,x.candidate_revision_id,x.created_at,x.input FROM veyra_strategy_screening x JOIN veyra_strategy_version v ON v.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=v.strategy_id WHERE x.id=$1`, id).Scan(&x.ID, &x.StrategyVersionID, &x.StrategyName, &x.StrategyRevision, &x.Symbol, &x.Exchange, &x.BarDate, &x.SourceID, &x.SourceVersion, &x.SourceAdapter, &x.ObservedAt, &x.ReceivedAt, &x.InputHash, &x.Status, &x.CandidateRevisionID, &x.CreatedAt, &x.Input) return x, mapError(err) }