Files
Veyra/internal/repository/trade_plan.go
T
Blizzard 92329ddce2
Checks / verify (push) Failing after 9m52s
feat: 完成模拟交易闭环、App 与 A 股行情接入
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
2026-10-08 22:58:52 +08:00

259 lines
11 KiB
Go

package repository
import (
"context"
"encoding/json"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"time"
)
type TradePlanRepository struct{ pool *pgxpool.Pool }
func NewTradePlanRepository(pool *pgxpool.Pool) *TradePlanRepository {
return &TradePlanRepository{pool}
}
func lockSimulationAccount(ctx context.Context, tx pgx.Tx, user, id string) error {
var mode, status string
if err := tx.QueryRow(ctx, `SELECT mode,ledger_status FROM veyra_account WHERE id=$1 AND user_id=$2 FOR UPDATE`, id, user).Scan(&mode, &status); err != nil {
return mapError(err)
}
if mode != "simulation" || status != "COMPLETE" {
return apperror.ErrInvalidInput
}
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()`
func budgetMinor(ctx context.Context, tx pgx.Tx, account, exclude string) (int64, int64, error) {
var cash, reserved int64
if err := tx.QueryRow(ctx, `SELECT COALESCE(sum(amount_minor),0)::bigint FROM veyra_cash_entry WHERE account_id=$1`, account).Scan(&cash); err != nil {
return 0, 0, mapError(err)
}
if err := tx.QueryRow(ctx, `SELECT COALESCE(sum(p.reserved_minor),0)::bigint 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 p.account_id=$1 AND p.id<>$2 AND `+activePlanPredicate, account, exclude).Scan(&reserved); err != nil {
return 0, 0, mapError(err)
}
return cash, reserved, nil
}
func (r *TradePlanRepository) Preview(ctx context.Context, user, account, candidate string, version, qty, fee int64) (model.PlanPreview, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return model.PlanPreview{}, mapError(err)
}
defer tx.Rollback(context.Background())
publication, err := lockCandidate(ctx, tx, candidate)
if err != nil {
return model.PlanPreview{}, err
}
if publication.Version != version || !publication.IsActivePublication {
return model.PlanPreview{}, apperror.ErrCandidateUnavailable
}
if err := lockSimulationAccount(ctx, tx, user, account); err != nil {
return model.PlanPreview{}, err
}
high, err := model.ParseMoney(publication.Snapshot.ReferencePlan.EntryHigh)
if err != nil {
return model.PlanPreview{}, err
}
needed := high*qty + fee
if needed <= 0 || needed > 100000000000 {
return model.PlanPreview{}, apperror.ErrInvalidInput
}
cash, reserved, err := budgetMinor(ctx, tx, account, "")
if err != nil {
return model.PlanPreview{}, err
}
if needed > cash-reserved {
return model.PlanPreview{}, apperror.ErrInsufficientCash
}
preview := model.PlanPreview{ID: uuid.NewString(), AccountID: account, CandidateRevisionID: candidate, CandidateVersion: version, Quantity: qty, FeeBudgetMinor: fee, ReservedMinor: needed, ReservedAmount: model.FormatMoney(needed), FeeBudget: model.FormatMoney(fee), ExpiresAt: time.Now().UTC().Add(5 * time.Minute)}
if publication.ValidUntil.Before(preview.ExpiresAt) {
preview.ExpiresAt = publication.ValidUntil
}
err = tx.QueryRow(ctx, `INSERT INTO veyra_plan_preview(id,account_id,candidate_revision_id,candidate_version,quantity,fee_budget_minor,reserved_minor,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING created_at`, preview.ID, account, candidate, version, qty, fee, needed, preview.ExpiresAt).Scan(&preview.CreatedAt)
if err != nil {
return preview, mapError(err)
}
return preview, mapError(tx.Commit(ctx))
}
const tradePlanColumns = `p.id,p.preview_id,p.account_id,p.candidate_revision_id,p.candidate_version,p.quantity,p.filled_quantity,p.fee_budget_minor,p.reserved_minor,p.status,p.version,p.valid_until,p.created_at,p.updated_at,r.snapshot`
const tradePlanJoin = ` 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 JOIN veyra_account a ON a.id=p.account_id `
func scanTradePlan(row pgx.Row) (model.TradePlan, error) {
var p model.TradePlan
var snapshot []byte
err := row.Scan(&p.ID, &p.PreviewID, &p.AccountID, &p.CandidateRevisionID, &p.CandidateVersion, &p.Quantity, &p.FilledQuantity, &p.FeeBudgetMinor, &p.ReservedMinor, &p.Status, &p.Version, &p.ValidUntil, &p.CreatedAt, &p.UpdatedAt, &snapshot)
if err != nil {
return p, mapError(err)
}
if json.Unmarshal(snapshot, &p.CandidateContent) != nil {
return p, apperror.ErrUnavailable
}
p.ReservedAmount = model.FormatMoney(p.ReservedMinor)
return p, nil
}
func (r *TradePlanRepository) Confirm(ctx context.Context, user, id, requestID string) (model.TradePlan, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return model.TradePlan{}, mapError(err)
}
defer tx.Rollback(context.Background())
var preview model.PlanPreview
err = tx.QueryRow(ctx, `SELECT v.id,v.account_id,v.candidate_revision_id,v.candidate_version,v.quantity,v.fee_budget_minor,v.reserved_minor,v.expires_at FROM veyra_plan_preview v JOIN veyra_account a ON a.id=v.account_id WHERE v.id=$1 AND a.user_id=$2`, id, user).Scan(&preview.ID, &preview.AccountID, &preview.CandidateRevisionID, &preview.CandidateVersion, &preview.Quantity, &preview.FeeBudgetMinor, &preview.ReservedMinor, &preview.ExpiresAt)
if err != nil {
return model.TradePlan{}, mapError(err)
}
publication, err := lockCandidate(ctx, tx, preview.CandidateRevisionID)
if err != nil {
return model.TradePlan{}, err
}
if err := lockSimulationAccount(ctx, tx, user, preview.AccountID); err != nil {
return model.TradePlan{}, err
}
// Retrying a confirmed preview returns the same plan, even after its preview expires.
existing, err := scanTradePlan(tx.QueryRow(ctx, `SELECT `+tradePlanColumns+tradePlanJoin+` WHERE p.preview_id=$1 AND a.user_id=$2`, id, user))
if err == nil {
return existing, nil
}
if err != apperror.ErrNotFound {
return existing, err
}
if !preview.ExpiresAt.After(time.Now()) {
return model.TradePlan{}, apperror.ErrPreviewExpired
}
if !publication.IsActivePublication || publication.Version != preview.CandidateVersion {
return model.TradePlan{}, apperror.ErrCandidateUnavailable
}
cash, reserved, err := budgetMinor(ctx, tx, preview.AccountID, "")
if err != nil {
return model.TradePlan{}, err
}
if preview.ReservedMinor > cash-reserved {
return model.TradePlan{}, apperror.ErrInsufficientCash
}
planID := uuid.NewString()
_, err = tx.Exec(ctx, `INSERT INTO veyra_trade_plan(id,preview_id,account_id,candidate_revision_id,candidate_version,quantity,fee_budget_minor,reserved_minor,valid_until) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, planID, id, preview.AccountID, preview.CandidateRevisionID, preview.CandidateVersion, preview.Quantity, preview.FeeBudgetMinor, preview.ReservedMinor, publication.ValidUntil)
if err != nil {
return model.TradePlan{}, mapError(err)
}
if err := appendAudit(ctx, tx, user, "PLAN_CONFIRMED", "trade_plan", planID, requestID, map[string]any{"preview_id": id, "reserved_amount": model.FormatMoney(preview.ReservedMinor)}); err != nil {
return model.TradePlan{}, err
}
plan, err := scanTradePlan(tx.QueryRow(ctx, `SELECT `+tradePlanColumns+tradePlanJoin+` WHERE p.id=$1`, planID))
if err != nil {
return plan, err
}
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)
if err != nil {
return mapError(err)
}
type invalid struct {
id string
version int64
}
list := make([]invalid, 0)
for rows.Next() {
var v invalid
if err := rows.Scan(&v.id, &v.version); err != nil {
rows.Close()
return mapError(err)
}
list = append(list, v)
}
rows.Close()
if err := rows.Err(); err != nil {
return mapError(err)
}
for _, v := range list {
eventID := uuid.NewString()
if err := appendAudit(ctx, tx, user, "PLAN_INVALIDATED", "trade_plan", v.id, eventID, map[string]any{"cause": "candidate_unavailable_or_expired", "version": v.version}); err != nil {
return err
}
_, err = tx.Exec(ctx, `INSERT INTO veyra_outbox_event(id,aggregate_type,aggregate_id,aggregate_version,event_type,payload) VALUES($1,'trade_plan',$2,$3,'plan.invalidated','{}'::jsonb)`, eventID, v.id, v.version)
if err != nil {
return mapError(err)
}
}
return nil
}
func (r *TradePlanRepository) List(ctx context.Context, user string) ([]model.TradePlan, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, mapError(err)
}
defer tx.Rollback(context.Background())
if err := invalidatePlans(ctx, tx, user); err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT `+tradePlanColumns+tradePlanJoin+` WHERE a.user_id=$1 ORDER BY p.created_at DESC,p.id DESC LIMIT 100`, user)
if err != nil {
return nil, mapError(err)
}
list := make([]model.TradePlan, 0)
for rows.Next() {
p, err := scanTradePlan(rows)
if err != nil {
rows.Close()
return nil, err
}
list = append(list, p)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, mapError(err)
}
return list, mapError(tx.Commit(ctx))
}
func (r *TradePlanRepository) Cancel(ctx context.Context, user, id string, version int64, requestID string) (model.TradePlan, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return model.TradePlan{}, mapError(err)
}
defer tx.Rollback(context.Background())
var account string
if err := tx.QueryRow(ctx, `SELECT p.account_id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id WHERE p.id=$1 AND a.user_id=$2`, id, user).Scan(&account); err != nil {
return model.TradePlan{}, mapError(err)
}
if err := lockSimulationAccount(ctx, tx, user, account); err != nil {
return model.TradePlan{}, err
}
tag, err := tx.Exec(ctx, `UPDATE veyra_trade_plan SET status='CANCELLED',reserved_minor=0,version=version+1,updated_at=now() WHERE id=$1 AND version=$2 AND status IN ('ACTIVE','PART_FILLED')`, id, version)
if err != nil {
return model.TradePlan{}, mapError(err)
}
if tag.RowsAffected() != 1 {
return model.TradePlan{}, apperror.ErrConflict
}
if err := appendAudit(ctx, tx, user, "PLAN_CANCELLED", "trade_plan", id, requestID, map[string]any{"input_version": version}); err != nil {
return model.TradePlan{}, err
}
plan, err := scanTradePlan(tx.QueryRow(ctx, `SELECT `+tradePlanColumns+tradePlanJoin+` WHERE p.id=$1`, id))
if err != nil {
return plan, err
}
return plan, mapError(tx.Commit(ctx))
}
func (r *TradePlanRepository) Budget(ctx context.Context, user, account string) (model.PlanBudget, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return model.PlanBudget{}, mapError(err)
}
defer tx.Rollback(context.Background())
if err := lockSimulationAccount(ctx, tx, user, account); err != nil {
return model.PlanBudget{}, err
}
cash, reserved, err := budgetMinor(ctx, tx, account, "")
if err != nil {
return model.PlanBudget{}, err
}
return model.PlanBudget{AccountID: account, CashBalance: model.FormatMoney(cash), ReservedAmount: model.FormatMoney(reserved), AvailableAmount: model.FormatMoney(cash - reserved)}, nil
}