feat: 完成模拟交易闭环、App 与 A 股行情接入
Checks / verify (push) Failing after 9m52s

统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
2026-10-08 22:58:52 +08:00
parent bffdb3858b
commit 92329ddce2
145 changed files with 18593 additions and 587 deletions
+42
View File
@@ -0,0 +1,42 @@
package service
import (
"context"
"strings"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"golang.org/x/crypto/bcrypt"
)
type AdminCredentialRepository interface {
ChangeAdminCredential(context.Context, string, string, string, time.Time) (string, error)
}
type AdminCredentialService struct {
repo AdminCredentialRepository
development bool
}
func NewAdminCredentialService(repo AdminCredentialRepository, development bool) *AdminCredentialService {
return &AdminCredentialService{repo: repo, development: development}
}
// Set changes only an existing active administrator and revokes its old sessions.
func (s *AdminCredentialService) Set(ctx context.Context, previousLogin, login, password string) (string, error) {
if !s.development {
return "", apperror.ErrForbidden
}
if s.repo == nil {
return "", apperror.ErrUnavailable
}
previousLogin = strings.ToLower(strings.TrimSpace(previousLogin))
login = strings.ToLower(strings.TrimSpace(login))
if !loginPattern.MatchString(previousLogin) || !loginPattern.MatchString(login) || len(password) < 8 || len(password) > 72 {
return "", apperror.ErrInvalidInput
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", apperror.ErrUnavailable
}
return s.repo.ChangeAdminCredential(ctx, previousLogin, login, string(hash), time.Now().UTC())
}
+7 -3
View File
@@ -23,7 +23,7 @@ import (
type AuthRepository interface {
CreateUser(context.Context, model.User, model.Credential) (model.User, error)
FindCredential(context.Context, string) (model.User, model.Credential, error)
CreateSession(context.Context, model.Session) error
CreateSession(context.Context, model.Session, string) error
FindSessionUser(context.Context, string, string, time.Time) (model.User, error)
RevokeSession(context.Context, string, string, time.Time) error
ListUsers(context.Context, int) ([]model.User, error)
@@ -49,7 +49,11 @@ var loginPattern = regexp.MustCompile(`^[a-z0-9_]{3,64}$`)
func (s *AuthService) ProvisionUser(ctx context.Context, login, password, name, role string) (model.User, error) {
login = strings.ToLower(strings.TrimSpace(login))
name = strings.TrimSpace(name)
if !loginPattern.MatchString(login) || len(password) < 12 || len(password) > 72 || utf8.RuneCountInString(name) > 80 || name == "" || (role != "USER" && role != "ADMIN") {
minimumPasswordLength := 12
if role == "ADMIN" {
minimumPasswordLength = 8
}
if !loginPattern.MatchString(login) || len(password) < minimumPasswordLength || len(password) > 72 || utf8.RuneCountInString(name) > 80 || name == "" || (role != "USER" && role != "ADMIN") {
return model.User{}, apperror.ErrInvalidInput
}
if s.repo == nil {
@@ -99,7 +103,7 @@ func (s *AuthService) Login(ctx context.Context, login, password, audience strin
token := base64.RawURLEncoding.EncodeToString(raw)
expires := s.now().UTC().Add(8 * time.Hour)
session := model.Session{ID: uuid.NewString(), UserID: user.ID, TokenHash: TokenHash(token), Audience: audience, ExpiresAt: expires}
if err := s.repo.CreateSession(ctx, session); err != nil {
if err := s.repo.CreateSession(ctx, session, credential.PasswordHash); err != nil {
return LoginResult{}, err
}
return LoginResult{User: user, Token: token, ExpiresAt: expires}, nil
+1 -1
View File
@@ -28,7 +28,7 @@ func (r *testRepository) FindCredential(_ context.Context, login string) (model.
}
return r.user, r.credential, nil
}
func (r *testRepository) CreateSession(_ context.Context, s model.Session) error {
func (r *testRepository) CreateSession(_ context.Context, s model.Session, _ string) error {
r.session = s
r.createdSessions++
return nil
+155
View File
@@ -0,0 +1,155 @@
package service
import (
"context"
"encoding/json"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
"go.uber.org/zap"
"strings"
"time"
"unicode/utf8"
)
type BacktestRepository interface {
Create(context.Context, model.BacktestInput, string, string) (model.BacktestRun, error)
List(context.Context) ([]model.BacktestRun, error)
Detail(context.Context, string) (model.BacktestRun, error)
Claim(context.Context) (model.BacktestRun, error)
Finish(context.Context, model.BacktestRun, json.RawMessage, string) error
}
type ResearchEngine interface {
Execute(context.Context, json.RawMessage) (json.RawMessage, error)
}
type BacktestService struct {
repo BacktestRepository
engine ResearchEngine
development bool
market *MarketService
}
func NewBacktestService(repo BacktestRepository, engine ResearchEngine, development bool, markets ...*MarketService) *BacktestService {
s := &BacktestService{repo: repo, engine: engine, development: development}
if len(markets) > 0 {
s.market = markets[0]
}
return s
}
func (s *BacktestService) authorize(u model.User) error {
if u.Role != "ADMIN" || u.Status != "ACTIVE" {
return apperror.ErrForbidden
}
if s == nil || s.repo == nil || !s.development {
return apperror.ErrUnavailable
}
return nil
}
func (s *BacktestService) List(ctx context.Context, u model.User) ([]model.BacktestRun, error) {
if e := s.authorize(u); e != nil {
return nil, e
}
return s.repo.List(ctx)
}
func (s *BacktestService) Detail(ctx context.Context, u model.User, id string) (model.BacktestRun, error) {
if e := s.authorize(u); e != nil {
return model.BacktestRun{}, e
}
if _, e := uuid.Parse(id); e != nil {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
return s.repo.Detail(ctx, id)
}
func (s *BacktestService) Create(ctx context.Context, u model.User, in model.BacktestInput, requestID string) (model.BacktestRun, error) {
in.DataOrigin = "manual"
in.SourceVersion = 0
in.DataReceivedAt = nil
return s.createValidated(ctx, u, in, requestID)
}
func (s *BacktestService) createValidated(ctx context.Context, u model.User, in model.BacktestInput, requestID string) (model.BacktestRun, error) {
if e := s.authorize(u); e != nil {
return model.BacktestRun{}, e
}
if _, e := uuid.Parse(in.StrategyVersionID); e != nil {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
in.DatasetName = strings.TrimSpace(in.DatasetName)
cash, e := model.ParseMoney(in.InitialCash)
if e != nil || cash < 10000 || cash > 100000000000 || in.DatasetName == "" || utf8.RuneCountInString(in.DatasetName) > 120 || len(in.Symbol) != 6 || len(in.Bars) < 4 || len(in.Bars) > 1000 {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
for _, r := range in.Symbol {
if r < '0' || r > '9' {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
}
last := ""
for _, b := range in.Bars {
date, e := time.Parse("2006-01-02", b.Date)
if e != nil || b.Date <= last || date.After(time.Now()) {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
last = b.Date
o, e1 := model.ParseMoney(b.Open)
h, e2 := model.ParseMoney(b.High)
l, e3 := model.ParseMoney(b.Low)
c, e4 := model.ParseMoney(b.Close)
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || l <= 0 || h > 100000000 || l > o || l > c || h < o || h < c {
return model.BacktestRun{}, apperror.ErrInvalidInput
}
}
in.InitialCash = model.FormatMoney(cash)
return s.repo.Create(ctx, in, u.ID, requestID)
}
func (s *BacktestService) ProcessNext(ctx context.Context) (bool, error) {
job, e := s.repo.Claim(ctx)
if e == apperror.ErrNotFound {
return false, nil
}
if e != nil {
return false, e
}
result, e := s.engine.Execute(ctx, job.Input)
if ctx.Err() != nil {
return true, ctx.Err()
} // The lease recovers interrupted work after restart.
failure := ""
if e != nil {
failure = "Python 执行失败,请检查解释器安装和输入;本任务未生成研究结果。"
result = nil
}
return true, s.repo.Finish(ctx, job, result, failure)
}
func (s *BacktestService) Run(ctx context.Context, logger *zap.Logger) {
if s == nil || s.repo == nil || s.engine == nil || !s.development {
return
}
timer := time.NewTicker(2 * time.Second)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
if _, e := s.ProcessNext(ctx); e != nil && ctx.Err() == nil {
logger.Error("backtest worker failed", zap.Error(e))
}
}
}
}
func (s *BacktestService) CreateMarket(ctx context.Context, u model.User, version, symbol, exchange, start, end, cash, requestID string) (model.BacktestRun, error) {
if e := s.authorize(u); e != nil {
return model.BacktestRun{}, e
}
if s.market == nil {
return model.BacktestRun{}, apperror.ErrUnavailable
}
history, e := s.market.History(ctx, u, symbol, exchange, start, end)
if e != nil {
return model.BacktestRun{}, e
}
input := model.BacktestInput{StrategyVersionID: version, Symbol: symbol, InitialCash: cash, Bars: history.Bars, DatasetName: history.Source + " 日线 · 不复权 · " + start + " 至 " + end, DataOrigin: history.Source, SourceVersion: history.SourceVersion, DataReceivedAt: &history.ReceivedAt}
return s.createValidated(ctx, u, input, requestID)
}
+102
View File
@@ -0,0 +1,102 @@
package service
import (
"context"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
)
type CandidateRepository interface {
List(context.Context, bool, string, int) ([]model.CandidateRevision, error)
Detail(context.Context, string, bool) (model.CandidateDetail, error)
Create(context.Context, model.CandidateDraftInput, string, string, string, int64) (model.CandidateRevision, error)
Transition(context.Context, string, string, model.CandidateActionInput, string, string) (model.CandidateRevision, error)
}
type CandidateService struct {
repo CandidateRepository
development bool
}
func NewCandidateService(repo CandidateRepository, development bool) *CandidateService {
return &CandidateService{repo: repo, development: development}
}
func (s *CandidateService) authorize(actor model.User, admin bool) error {
if actor.Status != "ACTIVE" || (admin && actor.Role != "ADMIN") || (!admin && actor.Role != "USER") {
return apperror.ErrForbidden
}
if s == nil || s.repo == nil {
return apperror.ErrUnavailable
}
// Manual simulation publishing is a development capability, not production authorization.
if !s.development {
return apperror.ErrUnavailable
}
return nil
}
func (s *CandidateService) List(ctx context.Context, actor model.User, admin bool, status string, limit int) ([]model.CandidateRevision, error) {
if err := s.authorize(actor, admin); err != nil {
return nil, err
}
if limit < 1 || limit > 100 {
return nil, apperror.ErrInvalidInput
}
switch status {
case "", "DRAFT", "PENDING_REVIEW", "PUBLISHED", "RETURNED", "WITHDRAWN":
default:
return nil, apperror.ErrInvalidInput
}
if !admin && status != "" {
return nil, apperror.ErrInvalidInput
}
return s.repo.List(ctx, admin, status, limit)
}
func (s *CandidateService) Detail(ctx context.Context, actor model.User, id string, admin bool) (model.CandidateDetail, error) {
if err := s.authorize(actor, admin); err != nil {
return model.CandidateDetail{}, err
}
if _, err := uuid.Parse(id); err != nil {
return model.CandidateDetail{}, apperror.ErrInvalidInput
}
return s.repo.Detail(ctx, id, admin)
}
func (s *CandidateService) Create(ctx context.Context, actor model.User, source, requestID string, input model.CandidateDraftInput, expected int64) (model.CandidateRevision, error) {
if err := s.authorize(actor, true); err != nil {
return model.CandidateRevision{}, err
}
if source != "" {
if _, err := uuid.Parse(source); err != nil {
return model.CandidateRevision{}, apperror.ErrInvalidInput
}
if expected < 1 {
return model.CandidateRevision{}, apperror.ErrInvalidInput
}
}
if _, err := uuid.Parse(requestID); err != nil {
return model.CandidateRevision{}, apperror.ErrInvalidInput
}
if err := model.ValidateDraft(input, time.Now().UTC()); err != nil {
return model.CandidateRevision{}, err
}
return s.repo.Create(ctx, input, actor.ID, requestID, source, expected)
}
func (s *CandidateService) Transition(ctx context.Context, actor model.User, id, action, requestID string, input model.CandidateActionInput) (model.CandidateRevision, error) {
if err := s.authorize(actor, true); err != nil {
return model.CandidateRevision{}, err
}
if _, err := uuid.Parse(id); err != nil {
return model.CandidateRevision{}, apperror.ErrInvalidInput
}
if _, err := uuid.Parse(requestID); err != nil {
return model.CandidateRevision{}, apperror.ErrInvalidInput
}
if input.ExpectedVersion < 1 {
return model.CandidateRevision{}, apperror.ErrInvalidInput
}
if err := model.ValidateReason(input.Reason); err != nil {
return model.CandidateRevision{}, err
}
return s.repo.Transition(ctx, id, action, input, actor.ID, requestID)
}
+145
View File
@@ -0,0 +1,145 @@
package service
import (
"context"
"errors"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"go.uber.org/zap"
"strings"
"time"
"unicode/utf8"
)
type MarketRepository interface {
List(context.Context, string) ([]model.Instrument, error)
Save(context.Context, model.ProviderConfig, []model.Instrument, time.Time, string) error
Tasks(context.Context) ([]model.MarketSync, error)
}
type MarketAdapter interface {
Snapshot(context.Context, model.ProviderConfig) ([]model.Instrument, error)
History(context.Context, model.ProviderConfig, string, string, string, string) (model.MarketHistory, error)
}
type MarketService struct {
repo MarketRepository
providers ProviderRepository
adapter MarketAdapter
development bool
}
func NewMarketService(repo MarketRepository, providers ProviderRepository, adapter MarketAdapter, development bool) *MarketService {
return &MarketService{repo, providers, adapter, development}
}
func (s *MarketService) authorize(u model.User) error {
if u.Status != "ACTIVE" || (u.Role != "ADMIN" && u.Role != "USER") {
return apperror.ErrForbidden
}
if s == nil || s.repo == nil || s.providers == nil || !s.development {
return apperror.ErrUnavailable
}
return nil
}
func (s *MarketService) configuration(ctx context.Context) (model.ProviderConfig, error) {
c, e := s.providers.Get(ctx, "market_primary")
if e != nil {
return c, e
}
if !c.Enabled || (c.MarketAdapter != "eastmoney_public" && c.MarketAdapter != "sina_public") {
return c, apperror.ErrUnavailable
}
return c, nil
}
func (s *MarketService) List(ctx context.Context, u model.User, query string) ([]model.Instrument, error) {
if e := s.authorize(u); e != nil {
return nil, e
}
query = strings.TrimSpace(query)
if utf8.RuneCountInString(query) > 80 {
return nil, apperror.ErrInvalidInput
}
return s.repo.List(ctx, query)
}
func (s *MarketService) Tasks(ctx context.Context, u model.User) ([]model.MarketSync, error) {
if e := s.authorize(u); e != nil {
return nil, e
}
if u.Role != "ADMIN" {
return nil, apperror.ErrForbidden
}
return s.repo.Tasks(ctx)
}
func (s *MarketService) History(ctx context.Context, u model.User, symbol, exchange, start, end string) (model.MarketHistory, error) {
if e := s.authorize(u); e != nil {
return model.MarketHistory{}, e
}
if len(symbol) != 6 || (exchange != "SSE" && exchange != "SZSE") {
return model.MarketHistory{}, apperror.ErrInvalidInput
}
for _, r := range symbol {
if r < '0' || r > '9' {
return model.MarketHistory{}, apperror.ErrInvalidInput
}
}
a, e1 := time.Parse("2006-01-02", start)
b, e2 := time.Parse("2006-01-02", end)
if e1 != nil || e2 != nil || a.After(b) || b.Sub(a) > 1460*24*time.Hour || b.After(time.Now()) {
return model.MarketHistory{}, apperror.ErrInvalidInput
}
c, e := s.configuration(ctx)
if e != nil {
return model.MarketHistory{}, e
}
result, e := s.adapter.History(ctx, c, symbol, exchange, start, end)
if e != nil {
return result, apperror.ErrUnavailable
}
return result, nil
}
func (s *MarketService) Sync(ctx context.Context) error {
c, e := s.configuration(ctx)
if errors.Is(e, apperror.ErrNotFound) || errors.Is(e, apperror.ErrUnavailable) {
return nil
}
if e != nil {
return e
}
started := time.Now().UTC()
list, e := s.adapter.Snapshot(ctx, c)
if ctx.Err() != nil {
return ctx.Err()
}
message := ""
if e != nil {
message = "行情同步失败,保留上次快照并标明行情时间。"
}
saveError := s.repo.Save(ctx, c, list, started, message)
if saveError != nil {
return saveError
}
return e
}
func (s *MarketService) Run(ctx context.Context, logger *zap.Logger) {
if s == nil || s.repo == nil || s.providers == nil || s.adapter == nil || !s.development {
return
}
var lastAttempt time.Time
var lastVersion int64
for {
config, err := s.configuration(ctx)
if err == nil && (config.Version != lastVersion || time.Since(lastAttempt) >= 5*time.Minute) {
lastAttempt = time.Now()
lastVersion = config.Version
task, cancel := context.WithTimeout(ctx, 90*time.Second)
err = s.Sync(task)
cancel()
if err != nil && ctx.Err() == nil {
logger.Warn("market synchronization failed", zap.Error(err))
}
}
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
}
}
}
+107
View File
@@ -0,0 +1,107 @@
package service
import (
"context"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
"go.uber.org/zap"
"time"
)
type NotificationRepository interface {
ProcessNext(context.Context) (bool, error)
List(context.Context, string) ([]model.Notification, error)
MarkRead(context.Context, string, string) error
Tasks(context.Context) ([]model.NotificationTask, error)
SaveQuote(context.Context, model.QuoteSnapshot, string, string) (model.QuoteSnapshot, error)
Quotes(context.Context) ([]model.QuoteSnapshot, error)
}
type NotificationService struct {
repo NotificationRepository
development bool
}
func NewNotificationService(repo NotificationRepository, development bool) *NotificationService {
return &NotificationService{repo, development}
}
func (s *NotificationService) authorize(actor model.User, admin bool) error {
if actor.Status != "ACTIVE" || (admin && actor.Role != "ADMIN") || (!admin && actor.Role != "USER") {
return apperror.ErrForbidden
}
if s == nil || s.repo == nil || !s.development {
return apperror.ErrUnavailable
}
return nil
}
func (s *NotificationService) Run(ctx context.Context, logger *zap.Logger) {
if s == nil || s.repo == nil || !s.development {
return
}
timer := time.NewTicker(2 * time.Second)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
for i := 0; i < 50; i++ {
worked, err := s.repo.ProcessNext(ctx)
if err != nil {
if ctx.Err() == nil {
logger.Error("notification processing failed")
}
break
}
if !worked {
break
}
}
}
}
}
func (s *NotificationService) List(ctx context.Context, actor model.User) ([]model.Notification, error) {
if err := s.authorize(actor, false); err != nil {
return nil, err
}
return s.repo.List(ctx, actor.ID)
}
func (s *NotificationService) MarkRead(ctx context.Context, actor model.User, id string) error {
if err := s.authorize(actor, false); err != nil {
return err
}
if _, err := uuid.Parse(id); err != nil {
return apperror.ErrInvalidInput
}
return s.repo.MarkRead(ctx, actor.ID, id)
}
func (s *NotificationService) Tasks(ctx context.Context, actor model.User) ([]model.NotificationTask, error) {
if err := s.authorize(actor, true); err != nil {
return nil, err
}
return s.repo.Tasks(ctx)
}
func (s *NotificationService) Quotes(ctx context.Context, actor model.User) ([]model.QuoteSnapshot, error) {
if err := s.authorize(actor, true); err != nil {
return nil, err
}
return s.repo.Quotes(ctx)
}
type SimulationQuoteInput struct {
Symbol string `json:"symbol"`
Exchange string `json:"exchange"`
Price string `json:"price"`
ObservedAt time.Time `json:"observed_at"`
}
func (s *NotificationService) SaveQuote(ctx context.Context, actor model.User, input SimulationQuoteInput, requestID string) (model.QuoteSnapshot, error) {
if err := s.authorize(actor, true); err != nil {
return model.QuoteSnapshot{}, err
}
price, err := model.ParseMoney(input.Price)
if err != nil || price == 0 || !fillSymbolPattern.MatchString(input.Symbol) || (input.Exchange != "SSE" && input.Exchange != "SZSE") || input.ObservedAt.Before(time.Now().Add(-time.Minute)) || input.ObservedAt.After(time.Now().Add(time.Minute)) {
return model.QuoteSnapshot{}, apperror.ErrInvalidInput
}
return s.repo.SaveQuote(ctx, model.QuoteSnapshot{Symbol: input.Symbol, Exchange: input.Exchange, PriceMinor: price, ObservedAt: input.ObservedAt, DataMode: "simulation"}, actor.ID, requestID)
}
+175
View File
@@ -0,0 +1,175 @@
package service
import (
"context"
"errors"
"net/url"
"strings"
"unicode/utf8"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"git.sundynix.cn/Blizzard/Veyra/internal/secret"
"github.com/google/uuid"
)
type ProviderRepository interface {
Get(context.Context, string) (model.ProviderConfig, error)
List(context.Context) ([]model.ProviderConfig, error)
Save(context.Context, model.ProviderConfig, int64, string, string, bool) (model.ProviderConfig, error)
Audit(context.Context) ([]model.AuditEvent, error)
}
type ProviderService struct {
repo ProviderRepository
vault *secret.Vault
}
func NewProviderService(repo ProviderRepository, vault *secret.Vault) *ProviderService {
return &ProviderService{repo: repo, vault: vault}
}
type ProviderInput struct {
QuoteBaseURL string `json:"quote_base_url"`
MarketAdapter string `json:"market_adapter"`
HistoryBaseURL string `json:"history_base_url"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
ModelName string `json:"model_name"`
WireAPI string `json:"wire_api"`
ModelCatalogURL string `json:"model_catalog_url"`
DisableResponseStorage *bool `json:"disable_response_storage"`
RequiresOpenAIAuth *bool `json:"requires_openai_auth"`
Enabled bool `json:"enabled"`
TimeoutSeconds int `json:"timeout_seconds"`
APIKey *string `json:"api_key,omitempty"`
ClearAPIKey bool `json:"clear_api_key"`
ExpectedVersion int64 `json:"expected_version"`
}
func providerKey(key string) bool {
return key == "model_primary" || key == "model_backup" || key == "model_review" || key == "market_primary"
}
func (s *ProviderService) authorize(actor model.User) error {
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
return apperror.ErrForbidden
}
if s == nil || s.repo == nil {
return apperror.ErrUnavailable
}
return nil
}
func (s *ProviderService) List(ctx context.Context, actor model.User) ([]model.ProviderConfig, error) {
if err := s.authorize(actor); err != nil {
return nil, err
}
return s.repo.List(ctx)
}
func (s *ProviderService) Audit(ctx context.Context, actor model.User) ([]model.AuditEvent, error) {
if err := s.authorize(actor); err != nil {
return nil, err
}
return s.repo.Audit(ctx)
}
func (s *ProviderService) Save(ctx context.Context, actor model.User, key, requestID string, input ProviderInput) (model.ProviderConfig, error) {
var c model.ProviderConfig
if err := s.authorize(actor); err != nil {
return c, err
}
if !providerKey(key) || input.ExpectedVersion < 0 {
return c, apperror.ErrInvalidInput
}
if _, err := uuid.Parse(requestID); err != nil {
return c, apperror.ErrInvalidInput
}
if input.MarketAdapter != "" && (key != "market_primary" || (input.MarketAdapter != "eastmoney_public" && input.MarketAdapter != "sina_public")) {
return c, apperror.ErrInvalidInput
}
if input.QuoteBaseURL != "" {
u, err := url.Parse(input.QuoteBaseURL)
if key != "market_primary" || err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || len(input.QuoteBaseURL) > 2048 {
return c, apperror.ErrInvalidInput
}
}
if input.Enabled && input.MarketAdapter == "sina_public" && (input.HistoryBaseURL == "" || input.QuoteBaseURL == "") {
return c, apperror.ErrInvalidInput
}
if input.HistoryBaseURL != "" {
u, err := url.Parse(input.HistoryBaseURL)
if key != "market_primary" || err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || len(input.HistoryBaseURL) > 2048 {
return c, apperror.ErrInvalidInput
}
}
if input.Enabled && input.MarketAdapter == "eastmoney_public" && input.HistoryBaseURL == "" {
return c, apperror.ErrInvalidInput
}
input.Name = strings.TrimSpace(input.Name)
input.BaseURL = strings.TrimSpace(input.BaseURL)
input.ModelName = strings.TrimSpace(input.ModelName)
input.ModelCatalogURL = strings.TrimSpace(input.ModelCatalogURL)
if input.WireAPI == "" {
input.WireAPI = "responses"
}
if input.WireAPI != "responses" && input.WireAPI != "chat_completions" {
return c, apperror.ErrInvalidInput
}
if utf8.RuneCountInString(input.Name) > 80 || utf8.RuneCountInString(input.ModelName) > 120 || len(input.BaseURL) > 2048 || len(input.ModelCatalogURL) > 2048 || input.TimeoutSeconds < 1 || input.TimeoutSeconds > 30 {
return c, apperror.ErrInvalidInput
}
if input.BaseURL != "" {
u, err := url.Parse(input.BaseURL)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" {
return c, apperror.ErrInvalidInput
}
// Credentials belong exclusively in the encrypted key field.
if strings.ContainsAny(u.Path, "\r\n") {
return c, apperror.ErrInvalidInput
}
}
if input.ModelCatalogURL != "" {
catalog, err := url.Parse(input.ModelCatalogURL)
base, baseErr := url.Parse(input.BaseURL)
if err != nil || baseErr != nil || catalog.Scheme != "https" || catalog.Host == "" || catalog.User != nil || catalog.RawQuery != "" || catalog.Fragment != "" || catalog.Opaque != "" || !strings.EqualFold(catalog.Host, base.Host) || base.Scheme != "https" {
return c, apperror.ErrInvalidInput
}
}
if input.APIKey != nil && (*input.APIKey == "" || len(*input.APIKey) > 4096 || strings.ContainsAny(*input.APIKey, "\r\n") || input.ClearAPIKey) {
return c, apperror.ErrInvalidInput
}
existing, err := s.repo.Get(ctx, key)
if err != nil && !errors.Is(err, apperror.ErrNotFound) {
return c, err
}
if existing.Version != input.ExpectedVersion {
return c, apperror.ErrConflict
}
if existing.BaseURL != input.BaseURL && len(existing.EncryptedAPIKey) > 0 && input.APIKey == nil && !input.ClearAPIKey {
return c, apperror.ErrInvalidInput
}
c = model.ProviderConfig{QuoteBaseURL: input.QuoteBaseURL, MarketAdapter: input.MarketAdapter, HistoryBaseURL: input.HistoryBaseURL, ConfigKey: key, Name: input.Name, BaseURL: input.BaseURL, ModelName: input.ModelName, Enabled: input.Enabled, TimeoutSeconds: input.TimeoutSeconds, EncryptedAPIKey: existing.EncryptedAPIKey, LastTestStatus: "NOT_CONFIGURED", WireAPI: input.WireAPI, ModelCatalogURL: input.ModelCatalogURL, DisableResponseStorage: true, RequiresOpenAIAuth: true}
if existing.Version > 0 {
c.DisableResponseStorage = existing.DisableResponseStorage
c.RequiresOpenAIAuth = existing.RequiresOpenAIAuth
}
if input.DisableResponseStorage != nil {
c.DisableResponseStorage = *input.DisableResponseStorage
}
if input.RequiresOpenAIAuth != nil {
c.RequiresOpenAIAuth = *input.RequiresOpenAIAuth
}
if input.ClearAPIKey {
c.EncryptedAPIKey = nil
}
if input.APIKey != nil {
c.EncryptedAPIKey, err = s.vault.Encrypt(*input.APIKey, key)
if err != nil {
return c, err
}
}
if c.Enabled {
if c.Name == "" || c.BaseURL == "" || (key != "market_primary" && (c.ModelName == "" || c.RequiresOpenAIAuth && len(c.EncryptedAPIKey) == 0)) {
return c, apperror.ErrInvalidInput
}
c.LastTestStatus = "NOT_TESTED"
}
return s.repo.Save(ctx, c, input.ExpectedVersion, actor.ID, requestID, input.APIKey != nil || input.ClearAPIKey)
}
+102
View File
@@ -0,0 +1,102 @@
package service
import (
"context"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
"regexp"
"strings"
"unicode/utf8"
)
type StrategyRepository interface {
List(context.Context) ([]model.Strategy, error)
Detail(context.Context, string) (model.StrategyDetail, error)
Save(context.Context, string, StrategyInput, string, string) (model.StrategyDetail, error)
Deploy(context.Context, string, string, string, int64, string, string, string) (model.StrategyDetail, error)
}
type StrategyService struct {
repo StrategyRepository
development bool
}
func NewStrategyService(repo StrategyRepository, development bool) *StrategyService {
return &StrategyService{repo, development}
}
func (s *StrategyService) authorize(actor model.User) error {
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
return apperror.ErrForbidden
}
if s == nil || s.repo == nil || !s.development {
return apperror.ErrUnavailable
}
return nil
}
type StrategyInput struct {
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
Parameters model.StrategyParameters `json:"parameters"`
ExpectedVersion int64 `json:"expected_version"`
}
var strategyCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`)
func (s *StrategyService) List(ctx context.Context, actor model.User) ([]model.Strategy, error) {
if err := s.authorize(actor); err != nil {
return nil, err
}
return s.repo.List(ctx)
}
func (s *StrategyService) Detail(ctx context.Context, actor model.User, id string) (model.StrategyDetail, error) {
if err := s.authorize(actor); err != nil {
return model.StrategyDetail{}, err
}
if _, err := uuid.Parse(id); err != nil {
return model.StrategyDetail{}, apperror.ErrInvalidInput
}
return s.repo.Detail(ctx, id)
}
func (s *StrategyService) Save(ctx context.Context, actor model.User, id string, input StrategyInput, requestID string) (model.StrategyDetail, error) {
if err := s.authorize(actor); err != nil {
return model.StrategyDetail{}, err
}
if id != "" {
if _, err := uuid.Parse(id); err != nil {
return model.StrategyDetail{}, apperror.ErrInvalidInput
}
if input.ExpectedVersion < 1 {
return model.StrategyDetail{}, apperror.ErrInvalidInput
}
}
p := input.Parameters
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
input.Description = strings.TrimSpace(input.Description)
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
}
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) {
if err := s.authorize(actor); err != nil {
return model.StrategyDetail{}, err
}
if _, err := uuid.Parse(id); err != nil || expected < 1 {
return model.StrategyDetail{}, apperror.ErrInvalidInput
}
if status != "ACTIVE" && status != "PAUSED" {
return model.StrategyDetail{}, apperror.ErrInvalidInput
}
if status == "ACTIVE" {
if _, err := uuid.Parse(versionID); err != nil {
return model.StrategyDetail{}, apperror.ErrInvalidInput
}
}
if err := model.ValidateReason(reason); err != nil {
return model.StrategyDetail{}, err
}
return s.repo.Deploy(ctx, id, versionID, status, expected, reason, actor.ID, requestID)
}
+152
View File
@@ -0,0 +1,152 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
"regexp"
"strings"
"time"
"unicode/utf8"
)
type TradeFillRepository interface {
Record(context.Context, string, model.TradeFill, string) (model.TradeFill, error)
List(context.Context, string, string) ([]model.TradeFill, error)
Reverse(context.Context, string, string, string, string) (model.TradeReversal, error)
Portfolio(context.Context, string, string) (model.PortfolioSummary, error)
}
type TradeFillService struct {
repo TradeFillRepository
development bool
}
func NewTradeFillService(repo TradeFillRepository, development bool) *TradeFillService {
return &TradeFillService{repo, development}
}
func (s *TradeFillService) authorize(actor model.User) error {
if actor.Role != "USER" || actor.Status != "ACTIVE" {
return apperror.ErrForbidden
}
if s == nil || s.repo == nil || !s.development {
return apperror.ErrUnavailable
}
return nil
}
type FillInput struct {
AccountID string `json:"account_id"`
PlanID *string `json:"plan_id"`
IdempotencyKey string `json:"idempotency_key"`
Side string `json:"side"`
Symbol string `json:"symbol"`
Exchange string `json:"exchange"`
Name string `json:"name"`
Quantity string `json:"quantity"`
Price string `json:"price"`
Fee string `json:"fee"`
OccurredAt time.Time `json:"occurred_at"`
SellableAt *time.Time `json:"sellable_at"`
Note string `json:"note"`
}
var fillSymbolPattern = regexp.MustCompile(`^[0-9]{6}$`)
func (s *TradeFillService) Record(ctx context.Context, actor model.User, input FillInput, requestID string) (model.TradeFill, error) {
if err := s.authorize(actor); err != nil {
return model.TradeFill{}, err
}
for _, id := range []string{input.AccountID, input.IdempotencyKey, requestID} {
if _, err := uuid.Parse(id); err != nil {
return model.TradeFill{}, apperror.ErrInvalidInput
}
}
if input.PlanID != nil {
if _, err := uuid.Parse(*input.PlanID); err != nil {
return model.TradeFill{}, apperror.ErrInvalidInput
}
}
qty, err := ParseQuantity(input.Quantity)
if err != nil {
return model.TradeFill{}, err
}
price, err := model.ParseMoney(input.Price)
if err != nil || price == 0 {
return model.TradeFill{}, apperror.ErrInvalidInput
}
fee, err := model.ParseMoney(input.Fee)
if err != nil {
return model.TradeFill{}, err
}
gross := price * qty
if gross > 100000000000 || gross+fee > 100000000000 {
return model.TradeFill{}, apperror.ErrInvalidInput
}
if !fillSymbolPattern.MatchString(input.Symbol) || (input.Exchange != "SSE" && input.Exchange != "SZSE") || strings.TrimSpace(input.Name) == "" || utf8.RuneCountInString(input.Name) > 80 || utf8.RuneCountInString(input.Note) > 1000 || input.OccurredAt.IsZero() || input.OccurredAt.After(time.Now().Add(time.Minute)) {
return model.TradeFill{}, apperror.ErrInvalidInput
}
delta := gross - fee
switch input.Side {
case "BUY":
if qty%100 != 0 || input.SellableAt == nil || input.SellableAt.Before(input.OccurredAt) {
return model.TradeFill{}, apperror.ErrInvalidInput
}
delta = -gross - fee
case "SELL":
if input.SellableAt != nil || fee > gross || input.PlanID != nil {
return model.TradeFill{}, apperror.ErrInvalidInput
}
default:
return model.TradeFill{}, apperror.ErrInvalidInput
}
input.Price = model.FormatMoney(price)
input.Fee = model.FormatMoney(fee)
input.Name = strings.TrimSpace(input.Name)
input.Note = strings.TrimSpace(input.Note)
input.OccurredAt = input.OccurredAt.UTC()
if input.SellableAt != nil {
t := input.SellableAt.UTC()
input.SellableAt = &t
}
encoded, err := json.Marshal(input)
if err != nil {
return model.TradeFill{}, apperror.ErrInvalidInput
}
digest := sha256.Sum256(encoded)
fill := model.TradeFill{ID: uuid.NewString(), AccountID: input.AccountID, PlanID: input.PlanID, IdempotencyKey: input.IdempotencyKey, InputHash: hex.EncodeToString(digest[:]), Side: input.Side, Symbol: input.Symbol, Exchange: input.Exchange, Name: input.Name, Quantity: qty, PriceMinor: price, FeeMinor: fee, CashDeltaMinor: delta, OccurredAt: input.OccurredAt, SellableAt: input.SellableAt, Note: input.Note}
return s.repo.Record(ctx, actor.ID, fill, requestID)
}
func (s *TradeFillService) List(ctx context.Context, actor model.User, account string) ([]model.TradeFill, error) {
if err := s.authorize(actor); err != nil {
return nil, err
}
if _, err := uuid.Parse(account); err != nil {
return nil, apperror.ErrInvalidInput
}
return s.repo.List(ctx, actor.ID, account)
}
func (s *TradeFillService) Reverse(ctx context.Context, actor model.User, id, reason, requestID string) (model.TradeReversal, error) {
if err := s.authorize(actor); err != nil {
return model.TradeReversal{}, err
}
if _, err := uuid.Parse(id); err != nil {
return model.TradeReversal{}, apperror.ErrInvalidInput
}
if err := model.ValidateReason(reason); err != nil {
return model.TradeReversal{}, err
}
return s.repo.Reverse(ctx, actor.ID, id, reason, requestID)
}
func (s *TradeFillService) Portfolio(ctx context.Context, actor model.User, account string) (model.PortfolioSummary, error) {
if err := s.authorize(actor); err != nil {
return model.PortfolioSummary{}, err
}
if _, err := uuid.Parse(account); err != nil {
return model.PortfolioSummary{}, apperror.ErrInvalidInput
}
return s.repo.Portfolio(ctx, actor.ID, account)
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"context"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
"regexp"
"strconv"
)
type TradePlanRepository interface {
Preview(context.Context, string, string, string, int64, int64, int64) (model.PlanPreview, error)
Confirm(context.Context, string, string, string) (model.TradePlan, error)
List(context.Context, string) ([]model.TradePlan, error)
Cancel(context.Context, string, string, int64, string) (model.TradePlan, error)
Budget(context.Context, string, string) (model.PlanBudget, error)
}
type TradePlanService struct {
repo TradePlanRepository
development bool
}
func NewTradePlanService(repo TradePlanRepository, development bool) *TradePlanService {
return &TradePlanService{repo, development}
}
func (s *TradePlanService) authorize(actor model.User) error {
if actor.Role != "USER" || actor.Status != "ACTIVE" {
return apperror.ErrForbidden
}
if s == nil || s.repo == nil || !s.development {
return apperror.ErrUnavailable
}
return nil
}
var quantityPattern = regexp.MustCompile(`^[1-9][0-9]{0,6}$`)
func ParseQuantity(value string) (int64, error) {
if !quantityPattern.MatchString(value) {
return 0, apperror.ErrInvalidInput
}
qty, err := strconv.ParseInt(value, 10, 64)
if err != nil || qty > 1000000 {
return 0, apperror.ErrInvalidInput
}
return qty, nil
}
type PlanPreviewInput struct {
AccountID string `json:"account_id"`
CandidateRevisionID string `json:"candidate_revision_id"`
ExpectedCandidateVersion int64 `json:"expected_candidate_version"`
Quantity string `json:"quantity"`
FeeBudget string `json:"fee_budget"`
}
func (s *TradePlanService) Preview(ctx context.Context, actor model.User, input PlanPreviewInput) (model.PlanPreview, error) {
if err := s.authorize(actor); err != nil {
return model.PlanPreview{}, err
}
if _, err := uuid.Parse(input.AccountID); err != nil {
return model.PlanPreview{}, apperror.ErrInvalidInput
}
if _, err := uuid.Parse(input.CandidateRevisionID); err != nil {
return model.PlanPreview{}, apperror.ErrInvalidInput
}
qty, err := ParseQuantity(input.Quantity)
if err != nil || qty%100 != 0 || input.ExpectedCandidateVersion < 1 {
return model.PlanPreview{}, apperror.ErrInvalidInput
}
fee, err := model.ParseMoney(input.FeeBudget)
if err != nil {
return model.PlanPreview{}, err
}
return s.repo.Preview(ctx, actor.ID, input.AccountID, input.CandidateRevisionID, input.ExpectedCandidateVersion, qty, fee)
}
func (s *TradePlanService) Confirm(ctx context.Context, actor model.User, id, requestID string) (model.TradePlan, error) {
if err := s.authorize(actor); err != nil {
return model.TradePlan{}, err
}
if _, err := uuid.Parse(id); err != nil {
return model.TradePlan{}, apperror.ErrInvalidInput
}
return s.repo.Confirm(ctx, actor.ID, id, requestID)
}
func (s *TradePlanService) List(ctx context.Context, actor model.User) ([]model.TradePlan, error) {
if err := s.authorize(actor); err != nil {
return nil, err
}
return s.repo.List(ctx, actor.ID)
}
func (s *TradePlanService) Cancel(ctx context.Context, actor model.User, id string, expected int64, requestID string) (model.TradePlan, error) {
if err := s.authorize(actor); err != nil {
return model.TradePlan{}, err
}
if _, err := uuid.Parse(id); err != nil || expected < 1 {
return model.TradePlan{}, apperror.ErrInvalidInput
}
return s.repo.Cancel(ctx, actor.ID, id, expected, requestID)
}
func (s *TradePlanService) Budget(ctx context.Context, actor model.User, id string) (model.PlanBudget, error) {
if err := s.authorize(actor); err != nil {
return model.PlanBudget{}, err
}
if _, err := uuid.Parse(id); err != nil {
return model.PlanBudget{}, apperror.ErrInvalidInput
}
return s.repo.Budget(ctx, actor.ID, id)
}