feat: 自动候选筛选与股票研究工作台
This commit is contained in:
@@ -19,25 +19,33 @@ func NewCandidateRepository(pool *pgxpool.Pool) *CandidateRepository {
|
||||
return &CandidateRepository{pool: pool}
|
||||
}
|
||||
|
||||
const candidateColumns = `r.id,r.candidate_id,r.revision,r.version,r.status,r.data_mode,r.snapshot,r.author_id,r.observed_at,r.valid_until,r.published_at,r.created_at,r.updated_at,(r.revision=c.latest_revision),COALESCE(c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now(),false),COALESCE((SELECT reason FROM veyra_candidate_review v WHERE v.revision_id=r.id AND v.decision='WITHDRAW' ORDER BY v.input_version DESC LIMIT 1),'')`
|
||||
// All successors of an automatically generated candidate retain the deployment binding.
|
||||
const screeningEligibility = `NOT EXISTS(SELECT 1 FROM veyra_strategy_screening x JOIN veyra_candidate_revision original ON original.id=x.candidate_revision_id JOIN veyra_strategy_version sv ON sv.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=sv.strategy_id WHERE original.candidate_id=c.id AND (s.status<>'ACTIVE' OR s.active_version_id IS DISTINCT FROM sv.id))`
|
||||
|
||||
const candidateColumns = `r.id,r.candidate_id,r.revision,r.version,r.status,r.data_mode,r.snapshot,r.author_id,r.observed_at,r.valid_until,r.published_at,r.created_at,r.updated_at,(r.revision=c.latest_revision),COALESCE(c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now() AND ` + screeningEligibility + `,false),COALESCE((SELECT reason FROM veyra_candidate_review v WHERE v.revision_id=r.id AND v.decision='WITHDRAW' ORDER BY v.input_version DESC LIMIT 1),''),(SELECT jsonb_build_object('id',x.id,'strategy_version_id',x.strategy_version_id,'strategy_name',s.name,'strategy_revision',sv.revision,'symbol',x.symbol,'exchange',x.exchange,'bar_date',x.bar_date::text,'source_id',x.source_id,'source_version',x.source_version,'source_adapter',x.source_adapter,'observed_at',x.observed_at,'received_at',x.received_at,'input_hash',x.input_hash,'status',x.status,'candidate_revision_id',x.candidate_revision_id,'created_at',x.created_at) FROM veyra_strategy_screening x JOIN veyra_candidate_revision original ON original.id=x.candidate_revision_id JOIN veyra_strategy_version sv ON sv.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=sv.strategy_id WHERE original.candidate_id=c.id LIMIT 1)`
|
||||
|
||||
func scanCandidate(row pgx.Row) (model.CandidateRevision, error) {
|
||||
var r model.CandidateRevision
|
||||
var snapshot []byte
|
||||
err := row.Scan(&r.ID, &r.CandidateID, &r.Revision, &r.Version, &r.Status, &r.DataMode, &snapshot, &r.AuthorID, &r.ObservedAt, &r.ValidUntil, &r.PublishedAt, &r.CreatedAt, &r.UpdatedAt, &r.IsLatest, &r.IsActivePublication, &r.WithdrawalReason)
|
||||
var snapshot, screening []byte
|
||||
err := row.Scan(&r.ID, &r.CandidateID, &r.Revision, &r.Version, &r.Status, &r.DataMode, &snapshot, &r.AuthorID, &r.ObservedAt, &r.ValidUntil, &r.PublishedAt, &r.CreatedAt, &r.UpdatedAt, &r.IsLatest, &r.IsActivePublication, &r.WithdrawalReason, &screening)
|
||||
if err != nil {
|
||||
return r, mapError(err)
|
||||
}
|
||||
if err := json.Unmarshal(snapshot, &r.Snapshot); err != nil {
|
||||
return r, apperror.ErrUnavailable
|
||||
}
|
||||
if len(screening) > 0 {
|
||||
if err := json.Unmarshal(screening, &r.Screening); err != nil {
|
||||
return r, apperror.ErrUnavailable
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
const candidateJoin = ` FROM veyra_candidate_revision r JOIN veyra_candidate c ON c.id=r.candidate_id `
|
||||
|
||||
func (r *CandidateRepository) List(ctx context.Context, admin bool, status string, limit int) ([]model.CandidateRevision, error) {
|
||||
query := `SELECT ` + candidateColumns + candidateJoin + ` WHERE ($1 AND (r.revision=c.latest_revision OR c.published_revision_id=r.id) AND ($2='' OR r.status=$2)) OR (NOT $1 AND c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now()) ORDER BY r.created_at DESC,r.id DESC LIMIT $3`
|
||||
query := `SELECT ` + candidateColumns + candidateJoin + ` WHERE ($1 AND (r.revision=c.latest_revision OR c.published_revision_id=r.id) AND ($2='' OR r.status=$2)) OR (NOT $1 AND c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now() AND ` + screeningEligibility + `) ORDER BY r.created_at DESC,r.id DESC LIMIT $3`
|
||||
rows, err := r.pool.Query(ctx, query, admin, status, limit)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
@@ -84,6 +92,22 @@ func lockCandidate(ctx context.Context, tx pgx.Tx, source string) (model.Candida
|
||||
if err := tx.QueryRow(ctx, `SELECT candidate_id FROM veyra_candidate_revision WHERE id=$1`, source).Scan(&parent); err != nil {
|
||||
return model.CandidateRevision{}, mapError(err)
|
||||
}
|
||||
// Match scanner lock order: deployment, then candidate aggregate.
|
||||
rows, err := tx.Query(ctx, `SELECT s.id FROM veyra_strategy s JOIN veyra_strategy_version v ON v.strategy_id=s.id JOIN veyra_strategy_screening x ON x.strategy_version_id=v.id JOIN veyra_candidate_revision original ON original.id=x.candidate_revision_id WHERE original.candidate_id=$1 ORDER BY s.id FOR SHARE OF s`, parent)
|
||||
if err != nil {
|
||||
return model.CandidateRevision{}, mapError(err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return model.CandidateRevision{}, mapError(err)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return model.CandidateRevision{}, mapError(err)
|
||||
}
|
||||
var locked string
|
||||
if err := tx.QueryRow(ctx, `SELECT id FROM veyra_candidate WHERE id=$1 FOR UPDATE`, parent).Scan(&locked); err != nil {
|
||||
return model.CandidateRevision{}, mapError(err)
|
||||
@@ -96,6 +120,15 @@ func (r *CandidateRepository) Create(ctx context.Context, input model.CandidateD
|
||||
return model.CandidateRevision{}, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
created, err := createCandidate(ctx, tx, input, actor, requestID, source, expected)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
return created, mapError(tx.Commit(ctx))
|
||||
}
|
||||
|
||||
func createCandidate(ctx context.Context, tx pgx.Tx, input model.CandidateDraftInput, actor, requestID, source string, expected int64) (model.CandidateRevision, error) {
|
||||
var err error
|
||||
parent, revision := uuid.NewString(), 1
|
||||
if source == "" {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_candidate(id,latest_revision) VALUES($1,1)`, parent)
|
||||
@@ -129,7 +162,7 @@ func (r *CandidateRepository) Create(ctx context.Context, input model.CandidateD
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
return created, mapError(tx.Commit(ctx))
|
||||
return created, nil
|
||||
}
|
||||
func appendCandidateEvent(ctx context.Context, tx pgx.Tx, r model.CandidateRevision, eventType string, details any) error {
|
||||
payload, err := json.Marshal(details)
|
||||
@@ -157,6 +190,13 @@ func (r *CandidateRepository) Transition(ctx context.Context, id, action string,
|
||||
return revision, err
|
||||
}
|
||||
if action == "PUBLISH" {
|
||||
var eligible bool
|
||||
if err := tx.QueryRow(ctx, `SELECT `+screeningEligibility+candidateJoin+` WHERE r.id=$1`, id).Scan(&eligible); err != nil {
|
||||
return revision, mapError(err)
|
||||
}
|
||||
if !eligible {
|
||||
return revision, apperror.ErrCandidateUnavailable
|
||||
}
|
||||
// Superseded publications keep their historical content, with an invalidation event.
|
||||
previous, err := scanCandidate(tx.QueryRow(ctx, `SELECT `+candidateColumns+candidateJoin+` WHERE c.id=$1 AND c.published_revision_id=r.id`, revision.CandidateID))
|
||||
if err != nil && err != apperror.ErrNotFound {
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"github.com/google/uuid"
|
||||
@@ -54,6 +55,41 @@ func (r *MarketRepository) Save(ctx context.Context, c model.ProviderConfig, lis
|
||||
return mapError(e)
|
||||
}
|
||||
}
|
||||
if message == "" {
|
||||
// Quote events are produced only for tracked plans and truly recent vendor observations.
|
||||
// Re-reading an unchanged close cannot enqueue another price event.
|
||||
rows, err := tx.Query(ctx, `INSERT INTO veyra_quote_snapshot(id,symbol,exchange,data_mode,price_minor,observed_at,version,source_id,source_version)
|
||||
SELECT gen_random_uuid()::text,i.symbol,i.exchange,'public',i.price_cents,i.observed_at,1,i.source_id,i.source_version FROM veyra_instrument i
|
||||
WHERE i.source_id=$1 AND i.source_version=$2 AND i.observed_at>=now()-interval '1 minute' AND i.observed_at<=now()+interval '1 minute'
|
||||
AND EXISTS(SELECT 1 FROM veyra_trade_plan p JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_candidate c ON c.id=r.candidate_id WHERE `+activePlanPredicate+` AND r.snapshot->>'symbol'=i.symbol AND r.snapshot->>'exchange'=i.exchange)
|
||||
ON CONFLICT(exchange,symbol,data_mode) DO UPDATE SET price_minor=EXCLUDED.price_minor,observed_at=EXCLUDED.observed_at,version=veyra_quote_snapshot.version+1,source_id=EXCLUDED.source_id,source_version=EXCLUDED.source_version,updated_at=now()
|
||||
WHERE EXCLUDED.observed_at>veyra_quote_snapshot.observed_at RETURNING `+quoteColumns, c.ID, c.Version)
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
quotes := make([]model.QuoteSnapshot, 0)
|
||||
for rows.Next() {
|
||||
q, err := scanQuote(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
quotes = append(quotes, q)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
for _, q := range quotes {
|
||||
payload, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
return apperror.ErrUnavailable
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO veyra_outbox_event(id,aggregate_type,aggregate_id,aggregate_version,event_type,payload) VALUES($1,'quote_snapshot',$2,$3,'quote.observed',$4)`, uuid.NewString(), q.ID, q.Version, payload); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, e = tx.Exec(ctx, `INSERT INTO veyra_market_sync(id,source_id,source_version,instrument_count,status,message,started_at) VALUES($1,$2,$3,$4,$5,$6,$7)`, uuid.NewString(), c.ID, c.Version, len(list), status, message, started)
|
||||
if e != nil {
|
||||
return mapError(e)
|
||||
|
||||
@@ -60,16 +60,16 @@ func (r *NotificationRepository) Tasks(ctx context.Context) ([]model.Notificatio
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
|
||||
const quoteColumns = `id,symbol,exchange,data_mode,price_minor,observed_at,version,created_at,updated_at`
|
||||
const quoteColumns = `id,symbol,exchange,data_mode,price_minor,observed_at,version,created_at,updated_at,source_id,source_version`
|
||||
|
||||
func scanQuote(row pgx.Row) (model.QuoteSnapshot, error) {
|
||||
var q model.QuoteSnapshot
|
||||
err := row.Scan(&q.ID, &q.Symbol, &q.Exchange, &q.DataMode, &q.PriceMinor, &q.ObservedAt, &q.Version, &q.CreatedAt, &q.UpdatedAt)
|
||||
err := row.Scan(&q.ID, &q.Symbol, &q.Exchange, &q.DataMode, &q.PriceMinor, &q.ObservedAt, &q.Version, &q.CreatedAt, &q.UpdatedAt, &q.SourceID, &q.SourceVersion)
|
||||
q.Price = model.FormatMoney(q.PriceMinor)
|
||||
return q, mapError(err)
|
||||
}
|
||||
func (r *NotificationRepository) Quotes(ctx context.Context) ([]model.QuoteSnapshot, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+quoteColumns+` FROM veyra_quote_snapshot ORDER BY updated_at DESC,id DESC LIMIT 100`)
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+quoteColumns+` FROM veyra_quote_snapshot WHERE data_mode='simulation' ORDER BY updated_at DESC,id DESC LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
@@ -127,6 +127,19 @@ func processQuote(ctx context.Context, tx pgx.Tx, event model.OutboxEvent) error
|
||||
if current.Version != event.AggregateVersion || time.Since(current.ObservedAt) > time.Minute || current.ObservedAt.After(time.Now().Add(time.Minute)) {
|
||||
return nil
|
||||
}
|
||||
if current.DataMode == "public" {
|
||||
if current.SourceID == nil || current.SourceVersion == nil {
|
||||
return apperror.ErrUnavailable
|
||||
}
|
||||
var version int64
|
||||
var enabled bool
|
||||
if err := tx.QueryRow(ctx, `SELECT version,enabled FROM veyra_provider_config WHERE id=$1 FOR SHARE`, *current.SourceID).Scan(&version, &enabled); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
if !enabled || version != *current.SourceVersion {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT p.id,p.candidate_revision_id,a.user_id FROM veyra_trade_plan p JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_account a ON a.id=p.account_id JOIN veyra_user u ON u.id=a.user_id WHERE p.status IN ('ACTIVE','PART_FILLED') AND u.status='ACTIVE' AND r.snapshot->>'symbol'=$1 AND r.snapshot->>'exchange'=$2 ORDER BY r.candidate_id,p.id`, current.Symbol, current.Exchange)
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
@@ -176,7 +189,11 @@ func processQuote(ctx context.Context, tx pgx.Tx, event model.OutboxEvent) error
|
||||
if tag.RowsAffected() == 0 {
|
||||
continue
|
||||
}
|
||||
body := fmt.Sprintf("模拟价格 ¥%s 进入 %s 的参考区间。观察时间 %s。请打开计划核对当前状态;此提醒不代表成交。", current.Price, publication.Snapshot.Name, current.ObservedAt.Format(time.RFC3339))
|
||||
label := "模拟价格"
|
||||
if current.DataMode == "public" {
|
||||
label = "公开行情价格"
|
||||
}
|
||||
body := fmt.Sprintf("%s ¥%s 进入 %s 的参考区间。观察时间 %s。请打开计划核对当前状态;此提醒不代表成交。", label, current.Price, publication.Snapshot.Name, current.ObservedAt.Format(time.RFC3339))
|
||||
if err := insertNotification(ctx, tx, target.user, event.ID, &target.plan, "模拟计划条件已满足", body); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -228,7 +245,7 @@ func (r *NotificationRepository) ProcessNext(ctx context.Context) (bool, error)
|
||||
var event model.OutboxEvent
|
||||
err = tx.QueryRow(ctx, `SELECT id,aggregate_type,aggregate_id,aggregate_version,event_type,payload,created_at,processed_at FROM veyra_outbox_event WHERE processed_at IS NULL ORDER BY created_at,id LIMIT 1 FOR UPDATE SKIP LOCKED`).Scan(&event.ID, &event.AggregateType, &event.AggregateID, &event.AggregateVersion, &event.EventType, &event.Payload, &event.CreatedAt, &event.ProcessedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
rows, err := tx.Query(ctx, `SELECT DISTINCT a.user_id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_candidate c ON c.id=r.candidate_id WHERE p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id) LIMIT 100`)
|
||||
rows, err := tx.Query(ctx, `SELECT DISTINCT a.user_id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_candidate c ON c.id=r.candidate_id WHERE p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id OR NOT (`+screeningEligibility+`)) LIMIT 100`)
|
||||
if err != nil {
|
||||
return false, mapError(err)
|
||||
}
|
||||
@@ -265,7 +282,7 @@ func (r *NotificationRepository) ProcessNext(ctx context.Context) (bool, error)
|
||||
var plan string
|
||||
err = tx.QueryRow(ctx, `SELECT a.user_id,p.id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id WHERE p.id=$1`, event.AggregateID).Scan(&user, &plan)
|
||||
if err == nil {
|
||||
err = insertNotification(ctx, tx, user, event.ID, &plan, "计划已失效", "对应候选已失效或计划已过期,保留预算已释放。已记录成交与持仓保留。")
|
||||
err = insertNotification(ctx, tx, user, event.ID, &plan, "计划已失效", "对应候选、策略版本已失效或计划已过期,保留预算已释放。已记录成交与持仓保留。")
|
||||
}
|
||||
case "candidate.published", "position.changed":
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func lockSimulationAccount(ctx context.Context, tx pgx.Tx, user, id string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
const activePlanPredicate = `p.status IN ('ACTIVE','PART_FILLED') AND p.valid_until>now() AND r.status='PUBLISHED' AND c.published_revision_id=r.id AND r.valid_until>now()`
|
||||
const activePlanPredicate = `p.status IN ('ACTIVE','PART_FILLED') AND p.valid_until>now() AND r.status='PUBLISHED' AND c.published_revision_id=r.id AND r.valid_until>now() AND ` + screeningEligibility
|
||||
|
||||
func budgetMinor(ctx context.Context, tx pgx.Tx, account, exclude string) (int64, int64, error) {
|
||||
var cash, reserved int64
|
||||
@@ -151,7 +151,7 @@ func (r *TradePlanRepository) Confirm(ctx context.Context, user, id, requestID s
|
||||
return plan, mapError(tx.Commit(ctx))
|
||||
}
|
||||
func invalidatePlans(ctx context.Context, tx pgx.Tx, user string) error {
|
||||
rows, err := tx.Query(ctx, `UPDATE veyra_trade_plan p SET status='INVALIDATED',reserved_minor=0,version=p.version+1,updated_at=now() FROM veyra_candidate_revision r,veyra_candidate c,veyra_account a WHERE p.candidate_revision_id=r.id AND c.id=r.candidate_id AND a.id=p.account_id AND a.user_id=$1 AND p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id) RETURNING p.id,p.version`, user)
|
||||
rows, err := tx.Query(ctx, `UPDATE veyra_trade_plan p SET status='INVALIDATED',reserved_minor=0,version=p.version+1,updated_at=now() FROM veyra_candidate_revision r,veyra_candidate c,veyra_account a WHERE p.candidate_revision_id=r.id AND c.id=r.candidate_id AND a.id=p.account_id AND a.user_id=$1 AND p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id OR NOT (`+screeningEligibility+`)) RETURNING p.id,p.version`, user)
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user