统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
@@ -27,7 +27,7 @@ func Handler() http.Handler {
|
||||
return
|
||||
}
|
||||
if _, err := fs.Stat(root, "index.html"); err != nil {
|
||||
http.Error(w, "Management console not built. Run make admin-build, then rebuild the API.", http.StatusServiceUnavailable)
|
||||
http.Error(w, "Management console not built. Run npm --prefix web/admin run build, then rebuild the Go executable.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if name != "" && name != "." {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
// Package apperror defines safe application errors shared across module boundaries.
|
||||
package apperror
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrUnauthenticated = errors.New("authentication required")
|
||||
ErrForbidden = errors.New("permission denied")
|
||||
ErrConflict = errors.New("already exists")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrUnavailable = errors.New("service unavailable")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrUnauthenticated = errors.New("authentication required")
|
||||
ErrForbidden = errors.New("permission denied")
|
||||
ErrConflict = errors.New("already exists")
|
||||
ErrInsufficientCash = fmt.Errorf("insufficient available cash: %w", ErrConflict)
|
||||
ErrInsufficientQuantity = fmt.Errorf("insufficient sellable quantity: %w", ErrConflict)
|
||||
ErrCandidateUnavailable = fmt.Errorf("candidate unavailable: %w", ErrConflict)
|
||||
ErrPreviewExpired = fmt.Errorf("preview expired: %w", ErrConflict)
|
||||
ErrChronology = fmt.Errorf("fill chronology conflict: %w", ErrConflict)
|
||||
ErrDependentFill = fmt.Errorf("later fill depends on this record: %w", ErrConflict)
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrUnavailable = errors.New("service unavailable")
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/logging"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -12,9 +13,12 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
PythonExecutable string
|
||||
Logging logging.Options
|
||||
Environment, HTTPAddr string
|
||||
PGHost, PGPort, PGDatabase, PGUser, PGPassword, PGSSLMode, PGAdminDatabase string
|
||||
RedisAddr, RedisUsername, RedisPassword string
|
||||
SecretEncryptionKey string
|
||||
RedisDB int
|
||||
}
|
||||
|
||||
@@ -57,6 +61,18 @@ func LoadEnvFile(path string) error {
|
||||
|
||||
func Load() (Config, error) {
|
||||
c := Config{Environment: env("APP_ENV", "local"), HTTPAddr: env("HTTP_ADDR", "127.0.0.1:8080"), PGHost: env("PGHOST", "192.168.100.127"), PGPort: env("PGPORT", "5432"), PGDatabase: env("PGDATABASE", "veyra"), PGUser: os.Getenv("PGUSER"), PGPassword: os.Getenv("PGPASSWORD"), PGSSLMode: env("PGSSLMODE", "disable"), PGAdminDatabase: env("PGADMIN_DATABASE", "postgres"), RedisAddr: env("REDIS_ADDR", "192.168.100.127:6379"), RedisUsername: os.Getenv("REDIS_USERNAME"), RedisPassword: os.Getenv("REDIS_PASSWORD")}
|
||||
c.PythonExecutable = env("PYTHON_EXECUTABLE", "python3")
|
||||
c.Logging = logging.Options{Directory: env("LOG_DIR", "logs"), Level: env("LOG_LEVEL", "info"), MaxSizeMB: 50, MaxBackups: 10, MaxAgeDays: 14}
|
||||
for key, dest := range map[string]*int{"LOG_MAX_SIZE_MB": &c.Logging.MaxSizeMB, "LOG_MAX_BACKUPS": &c.Logging.MaxBackups, "LOG_MAX_AGE_DAYS": &c.Logging.MaxAgeDays} {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n < 1 {
|
||||
return Config{}, fmt.Errorf("%s must be a positive integer", key)
|
||||
}
|
||||
*dest = n
|
||||
}
|
||||
}
|
||||
c.SecretEncryptionKey = os.Getenv("SECRET_ENCRYPTION_KEY")
|
||||
var err error
|
||||
c.RedisDB, err = strconv.Atoi(env("REDIS_DB", "0"))
|
||||
if err != nil || c.RedisDB < 0 {
|
||||
@@ -82,7 +98,7 @@ func Load() (Config, error) {
|
||||
|
||||
func (c Config) ValidateDatabase() error {
|
||||
if c.PGUser == "" {
|
||||
return fmt.Errorf("PGUSER is required; configure the local .env file")
|
||||
return fmt.Errorf("PGUSER is required; configure the local configs/.env file")
|
||||
}
|
||||
if c.PGHost == "" || c.PGDatabase == "" || c.PGAdminDatabase == "" {
|
||||
return fmt.Errorf("database host and names are required")
|
||||
|
||||
@@ -48,8 +48,12 @@ func TestMigrationIntegration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
files, err := migrations.ReadDir("migrations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_schema_migration").Scan(&count); err != nil || count != 2 {
|
||||
if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_schema_migration").Scan(&count); err != nil || count != len(files) {
|
||||
t.Fatalf("migration ledger count=%d err=%v", count, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO veyra_user(id,display_name) VALUES($1,$2)", uuid.NewString(), "Test"); err != nil {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE veyra_provider_config (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
config_key varchar(32) NOT NULL UNIQUE CHECK (config_key IN ('model_primary','model_backup','market_primary')),
|
||||
name varchar(80) NOT NULL,
|
||||
base_url text NOT NULL DEFAULT '',
|
||||
model_name varchar(120) NOT NULL DEFAULT '',
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
timeout_seconds integer NOT NULL DEFAULT 15 CHECK (timeout_seconds BETWEEN 1 AND 30),
|
||||
encrypted_api_key bytea,
|
||||
version bigint NOT NULL CHECK (version > 0),
|
||||
last_test_status varchar(24) NOT NULL DEFAULT 'NOT_TESTED' CHECK (last_test_status IN ('NOT_CONFIGURED','NOT_TESTED','READY','FAILED')),
|
||||
last_tested_at timestamptz,
|
||||
last_test_message varchar(200) NOT NULL DEFAULT '',
|
||||
updated_by varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE veyra_provider_config_history (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
config_id varchar(36) NOT NULL REFERENCES veyra_provider_config(id),
|
||||
version bigint NOT NULL CHECK (version > 0),
|
||||
snapshot jsonb NOT NULL,
|
||||
encrypted_api_key bytea,
|
||||
actor_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (config_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE veyra_audit_event (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
actor_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||
action varchar(40) NOT NULL,
|
||||
object_type varchar(40) NOT NULL,
|
||||
object_id varchar(36) NOT NULL CHECK (object_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
request_id varchar(36) NOT NULL CHECK (request_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
details jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX veyra_audit_event_created_at_idx ON veyra_audit_event(created_at DESC, id DESC);
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE veyra_provider_config
|
||||
DROP CONSTRAINT veyra_provider_config_config_key_check,
|
||||
ADD CONSTRAINT veyra_provider_config_config_key_check
|
||||
CHECK (config_key IN ('model_primary','model_backup','model_review','market_primary')),
|
||||
ADD COLUMN wire_api varchar(32) NOT NULL DEFAULT 'responses'
|
||||
CHECK (wire_api IN ('responses','chat_completions')),
|
||||
ADD COLUMN model_catalog_url text NOT NULL DEFAULT '',
|
||||
ADD COLUMN disable_response_storage boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN requires_openai_auth boolean NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,52 @@
|
||||
CREATE TABLE veyra_candidate (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
latest_revision integer NOT NULL CHECK (latest_revision > 0),
|
||||
published_revision_id varchar(36),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE veyra_candidate_revision (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
candidate_id varchar(36) NOT NULL REFERENCES veyra_candidate(id),
|
||||
revision integer NOT NULL CHECK (revision > 0),
|
||||
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
status varchar(24) NOT NULL CHECK (status IN ('DRAFT', 'PENDING_REVIEW', 'PUBLISHED', 'RETURNED', 'WITHDRAWN')),
|
||||
data_mode varchar(16) NOT NULL CHECK (data_mode = 'simulation'),
|
||||
snapshot jsonb NOT NULL CHECK (jsonb_typeof(snapshot) = 'object'),
|
||||
author_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||
observed_at timestamptz NOT NULL,
|
||||
valid_until timestamptz NOT NULL,
|
||||
published_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (valid_until > observed_at),
|
||||
UNIQUE (candidate_id, revision),
|
||||
UNIQUE (candidate_id, id)
|
||||
);
|
||||
ALTER TABLE veyra_candidate ADD CONSTRAINT veyra_candidate_published_revision_fk
|
||||
FOREIGN KEY (id, published_revision_id) REFERENCES veyra_candidate_revision(candidate_id, id);
|
||||
CREATE INDEX veyra_candidate_revision_queue_idx ON veyra_candidate_revision(status, created_at DESC, id DESC);
|
||||
|
||||
CREATE TABLE veyra_candidate_review (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
revision_id varchar(36) NOT NULL REFERENCES veyra_candidate_revision(id),
|
||||
reviewer_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||
decision varchar(16) NOT NULL CHECK (decision IN ('SUBMIT', 'PUBLISH', 'RETURN', 'WITHDRAW')),
|
||||
input_version bigint NOT NULL CHECK (input_version > 0),
|
||||
reason text NOT NULL CHECK (length(trim(reason)) BETWEEN 1 AND 1000),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (revision_id, input_version)
|
||||
);
|
||||
|
||||
CREATE TABLE veyra_outbox_event (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
aggregate_type varchar(40) NOT NULL,
|
||||
aggregate_id varchar(36) NOT NULL CHECK (aggregate_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
aggregate_version bigint NOT NULL CHECK (aggregate_version > 0),
|
||||
event_type varchar(64) NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
processed_at timestamptz,
|
||||
UNIQUE (aggregate_id, aggregate_version, event_type)
|
||||
);
|
||||
CREATE INDEX veyra_outbox_event_pending_idx ON veyra_outbox_event(created_at, id) WHERE processed_at IS NULL;
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE veyra_plan_preview (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
account_id varchar(36) NOT NULL REFERENCES veyra_account(id),
|
||||
candidate_revision_id varchar(36) NOT NULL REFERENCES veyra_candidate_revision(id),
|
||||
candidate_version bigint NOT NULL CHECK (candidate_version > 0),
|
||||
quantity bigint NOT NULL CHECK (quantity > 0 AND quantity <= 1000000 AND quantity % 100 = 0),
|
||||
fee_budget_minor bigint NOT NULL CHECK (fee_budget_minor >= 0),
|
||||
reserved_minor bigint NOT NULL CHECK (reserved_minor > 0 AND reserved_minor <= 100000000000),
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE veyra_trade_plan (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
preview_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_plan_preview(id),
|
||||
account_id varchar(36) NOT NULL REFERENCES veyra_account(id),
|
||||
candidate_revision_id varchar(36) NOT NULL REFERENCES veyra_candidate_revision(id),
|
||||
candidate_version bigint NOT NULL CHECK (candidate_version > 0),
|
||||
quantity bigint NOT NULL CHECK (quantity > 0 AND quantity <= 1000000),
|
||||
filled_quantity bigint NOT NULL DEFAULT 0 CHECK (filled_quantity >= 0 AND filled_quantity <= quantity),
|
||||
fee_budget_minor bigint NOT NULL CHECK (fee_budget_minor >= 0),
|
||||
reserved_minor bigint NOT NULL CHECK (reserved_minor >= 0 AND reserved_minor <= 100000000000),
|
||||
status varchar(24) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE','PART_FILLED','COMPLETED','CANCELLED','INVALIDATED')),
|
||||
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
valid_until timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX veyra_trade_plan_account_idx ON veyra_trade_plan(account_id,status);
|
||||
@@ -0,0 +1,71 @@
|
||||
ALTER TABLE veyra_cash_entry DROP CONSTRAINT veyra_cash_entry_entry_type_check;
|
||||
ALTER TABLE veyra_cash_entry DROP CONSTRAINT veyra_cash_entry_amount_minor_check;
|
||||
ALTER TABLE veyra_cash_entry DROP CONSTRAINT veyra_cash_entry_account_id_entry_type_key;
|
||||
ALTER TABLE veyra_cash_entry ADD CONSTRAINT veyra_cash_entry_entry_type_check CHECK(entry_type IN ('INITIAL','BUY','SELL','REVERSAL'));
|
||||
ALTER TABLE veyra_cash_entry ADD CONSTRAINT veyra_cash_entry_amount_minor_check CHECK(amount_minor BETWEEN -100000000000 AND 100000000000);
|
||||
CREATE UNIQUE INDEX veyra_cash_entry_initial_idx ON veyra_cash_entry(account_id) WHERE entry_type='INITIAL';
|
||||
CREATE TABLE veyra_trade_fill (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
account_id varchar(36) NOT NULL REFERENCES veyra_account(id),
|
||||
plan_id varchar(36) REFERENCES veyra_trade_plan(id),
|
||||
idempotency_key varchar(36) NOT NULL CHECK (idempotency_key ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
input_hash varchar(64) NOT NULL,
|
||||
posted_sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE,
|
||||
side varchar(4) NOT NULL CHECK(side IN ('BUY','SELL')),
|
||||
symbol varchar(6) NOT NULL CHECK(symbol ~ '^[0-9]{6}$'),
|
||||
exchange varchar(4) NOT NULL CHECK(exchange IN ('SSE','SZSE')),
|
||||
name varchar(80) NOT NULL,
|
||||
quantity bigint NOT NULL CHECK(quantity BETWEEN 1 AND 1000000),
|
||||
price_minor bigint NOT NULL CHECK(price_minor>0 AND price_minor<=100000000000),
|
||||
fee_minor bigint NOT NULL CHECK(fee_minor>=0 AND fee_minor<=100000000000),
|
||||
cash_delta_minor bigint NOT NULL CHECK(cash_delta_minor BETWEEN -100000000000 AND 100000000000),
|
||||
realized_minor bigint NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
sellable_at timestamptz,
|
||||
note text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK(side='SELL' OR sellable_at>=occurred_at),
|
||||
UNIQUE(account_id,idempotency_key)
|
||||
);
|
||||
CREATE TABLE veyra_position_lot (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
account_id varchar(36) NOT NULL REFERENCES veyra_account(id),
|
||||
buy_fill_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_trade_fill(id),
|
||||
symbol varchar(6) NOT NULL,
|
||||
exchange varchar(4) NOT NULL,
|
||||
remaining_quantity bigint NOT NULL CHECK(remaining_quantity>=0),
|
||||
remaining_cost_minor bigint NOT NULL CHECK(remaining_cost_minor>=0),
|
||||
sellable_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX veyra_position_lot_account_idx ON veyra_position_lot(account_id,exchange,symbol,sellable_at);
|
||||
CREATE TABLE veyra_fill_allocation (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
sell_fill_id varchar(36) NOT NULL REFERENCES veyra_trade_fill(id),
|
||||
lot_id varchar(36) NOT NULL REFERENCES veyra_position_lot(id),
|
||||
quantity bigint NOT NULL CHECK(quantity>0),
|
||||
cost_minor bigint NOT NULL CHECK(cost_minor>=0),
|
||||
UNIQUE(sell_fill_id,lot_id)
|
||||
);
|
||||
CREATE TABLE veyra_trade_reversal (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
fill_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_trade_fill(id),
|
||||
actor_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||
reason text NOT NULL CHECK(length(trim(reason)) BETWEEN 1 AND 1000),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE veyra_cash_entry ADD COLUMN fill_id varchar(36) REFERENCES veyra_trade_fill(id);
|
||||
ALTER TABLE veyra_cash_entry ADD COLUMN reversal_id varchar(36) REFERENCES veyra_trade_reversal(id);
|
||||
CREATE UNIQUE INDEX veyra_cash_entry_fill_idx ON veyra_cash_entry(fill_id) WHERE fill_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX veyra_cash_entry_reversal_idx ON veyra_cash_entry(reversal_id) WHERE reversal_id IS NOT NULL;
|
||||
|
||||
CREATE FUNCTION veyra_reject_fact_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'posted facts are immutable; use a reversal';
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER veyra_trade_fill_immutable BEFORE UPDATE OR DELETE ON veyra_trade_fill FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||
CREATE TRIGGER veyra_cash_entry_immutable BEFORE UPDATE OR DELETE ON veyra_cash_entry FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||
CREATE TRIGGER veyra_fill_allocation_immutable BEFORE UPDATE OR DELETE ON veyra_fill_allocation FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||
CREATE TRIGGER veyra_trade_reversal_immutable BEFORE UPDATE OR DELETE ON veyra_trade_reversal FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||
CREATE TRIGGER veyra_candidate_review_immutable BEFORE UPDATE OR DELETE ON veyra_candidate_review FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE veyra_quote_snapshot (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
symbol varchar(6) NOT NULL CHECK (symbol ~ '^[0-9]{6}$'),
|
||||
exchange varchar(4) NOT NULL CHECK (exchange IN ('SSE','SZSE')),
|
||||
data_mode varchar(16) NOT NULL CHECK (data_mode='simulation'),
|
||||
price_minor bigint NOT NULL CHECK (price_minor>0 AND price_minor<=100000000000),
|
||||
observed_at timestamptz NOT NULL,
|
||||
version bigint NOT NULL CHECK (version>0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(exchange,symbol,data_mode)
|
||||
);
|
||||
CREATE TABLE veyra_notification (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
user_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||
event_id varchar(36) NOT NULL REFERENCES veyra_outbox_event(id),
|
||||
plan_id varchar(36) REFERENCES veyra_trade_plan(id),
|
||||
title varchar(120) NOT NULL,
|
||||
body text NOT NULL,
|
||||
read_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(user_id,event_id,plan_id)
|
||||
);
|
||||
CREATE INDEX veyra_notification_user_idx ON veyra_notification(user_id,created_at DESC);
|
||||
CREATE TABLE veyra_plan_trigger (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
plan_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_trade_plan(id),
|
||||
quote_snapshot_id varchar(36) NOT NULL REFERENCES veyra_quote_snapshot(id),
|
||||
quote_version bigint NOT NULL CHECK(quote_version>0),
|
||||
triggered_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX veyra_notification_unread_idx ON veyra_notification(user_id) WHERE read_at IS NULL;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE veyra_strategy (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
code varchar(64) NOT NULL UNIQUE CHECK (code ~ '^[a-z][a-z0-9_]{2,63}$'),
|
||||
name varchar(80) NOT NULL,
|
||||
status varchar(16) NOT NULL DEFAULT 'PAUSED' CHECK(status IN ('ACTIVE','PAUSED')),
|
||||
latest_revision integer NOT NULL DEFAULT 1 CHECK(latest_revision>0),
|
||||
active_version_id varchar(36),
|
||||
version bigint NOT NULL DEFAULT 1 CHECK(version>0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE veyra_strategy_version (
|
||||
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
strategy_id varchar(36) NOT NULL REFERENCES veyra_strategy(id),
|
||||
revision integer NOT NULL CHECK(revision>0),
|
||||
algorithm varchar(32) NOT NULL CHECK(algorithm='sma_cross'),
|
||||
parameters jsonb NOT NULL,
|
||||
description text NOT NULL,
|
||||
author_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(strategy_id,revision),
|
||||
UNIQUE(strategy_id,id)
|
||||
);
|
||||
ALTER TABLE veyra_strategy ADD CONSTRAINT veyra_strategy_active_version_fk FOREIGN KEY(id,active_version_id) REFERENCES veyra_strategy_version(strategy_id,id);
|
||||
CREATE TRIGGER veyra_strategy_version_immutable BEFORE UPDATE OR DELETE ON veyra_strategy_version FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE veyra_backtest_run (
|
||||
id varchar(36) PRIMARY KEY CHECK(id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
strategy_version_id varchar(36) NOT NULL REFERENCES veyra_strategy_version(id),
|
||||
status varchar(16) NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','RUNNING','COMPLETED','FAILED')),
|
||||
input jsonb NOT NULL,
|
||||
input_hash varchar(64) NOT NULL,
|
||||
result jsonb,
|
||||
failure text NOT NULL DEFAULT '',
|
||||
lease_id varchar(36) CHECK(lease_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
lease_expires_at timestamptz,
|
||||
author_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
CHECK((status='RUNNING') = (lease_id IS NOT NULL AND lease_expires_at IS NOT NULL)),
|
||||
CHECK((status IN ('COMPLETED','FAILED')) = (finished_at IS NOT NULL)),
|
||||
CHECK((status='COMPLETED') = (result IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX veyra_backtest_run_queue_idx ON veyra_backtest_run(status,created_at);
|
||||
CREATE FUNCTION veyra_protect_backtest_run() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' OR OLD.status IN ('COMPLETED','FAILED') THEN RAISE EXCEPTION 'immutable research result'; END IF;
|
||||
IF NEW.id<>OLD.id OR NEW.strategy_version_id<>OLD.strategy_version_id OR NEW.input<>OLD.input OR NEW.input_hash<>OLD.input_hash OR NEW.author_id<>OLD.author_id OR NEW.created_at<>OLD.created_at THEN RAISE EXCEPTION 'immutable research input'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER veyra_backtest_run_protected BEFORE UPDATE OR DELETE ON veyra_backtest_run FOR EACH ROW EXECUTE FUNCTION veyra_protect_backtest_run();
|
||||
@@ -0,0 +1,24 @@
|
||||
ALTER TABLE veyra_provider_config ADD COLUMN market_adapter varchar(32) NOT NULL DEFAULT '' CHECK(market_adapter IN ('','eastmoney_public')), ADD COLUMN history_base_url text NOT NULL DEFAULT '';
|
||||
CREATE TABLE veyra_instrument (
|
||||
id varchar(36) PRIMARY KEY CHECK(id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
symbol varchar(6) NOT NULL CHECK(symbol ~ '^[0-9]{6}$'),
|
||||
exchange varchar(8) NOT NULL CHECK(exchange IN ('SSE','SZSE')),
|
||||
name varchar(80) NOT NULL,
|
||||
price_cents bigint NOT NULL CHECK(price_cents>=0),
|
||||
observed_at timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
source_id varchar(36) NOT NULL REFERENCES veyra_provider_config(id),
|
||||
source_version bigint NOT NULL CHECK(source_version>0),
|
||||
UNIQUE(exchange,symbol)
|
||||
);
|
||||
CREATE TABLE veyra_market_sync (
|
||||
id varchar(36) PRIMARY KEY CHECK(id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||
source_id varchar(36) NOT NULL REFERENCES veyra_provider_config(id),
|
||||
source_version bigint NOT NULL CHECK(source_version>0),
|
||||
instrument_count integer NOT NULL CHECK(instrument_count>=0),
|
||||
status varchar(16) NOT NULL CHECK(status IN ('COMPLETED','FAILED')),
|
||||
message text NOT NULL DEFAULT '',
|
||||
started_at timestamptz NOT NULL,
|
||||
finished_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX veyra_instrument_symbol_idx ON veyra_instrument(symbol);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE veyra_provider_config DROP CONSTRAINT veyra_provider_config_market_adapter_check, ADD CONSTRAINT veyra_provider_config_market_adapter_check CHECK(market_adapter IN ('','eastmoney_public','sina_public')), ADD COLUMN quote_base_url text NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func bindBacktests(r *gin.Engine, o IdentityOptions) {
|
||||
g := r.Group("/api/admin/v1/backtests", func(c *gin.Context) { authenticate(c, o, "admin") })
|
||||
g.Use(func(c *gin.Context) {
|
||||
if o.Backtests == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
g.GET("", func(c *gin.Context) {
|
||||
v, e := o.Backtests.List(c.Request.Context(), currentUser(c))
|
||||
if e != nil {
|
||||
fail(c, e)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, v)
|
||||
})
|
||||
g.GET("/:id", func(c *gin.Context) {
|
||||
v, e := o.Backtests.Detail(c.Request.Context(), currentUser(c), c.Param("id"))
|
||||
if e != nil {
|
||||
fail(c, e)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, v)
|
||||
})
|
||||
g.POST("", func(c *gin.Context) {
|
||||
if !sameOrigin(c.Request) {
|
||||
fail(c, apperror.ErrForbidden)
|
||||
return
|
||||
}
|
||||
var input model.BacktestInput
|
||||
if !decodeLimit(c, &input, 256<<10) {
|
||||
return
|
||||
}
|
||||
v, e := o.Backtests.Create(c.Request.Context(), currentUser(c), input, c.Writer.Header().Get("X-Request-ID"))
|
||||
if e != nil {
|
||||
fail(c, e)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusAccepted, v)
|
||||
})
|
||||
g.POST("/from-market", func(c *gin.Context) {
|
||||
if !sameOrigin(c.Request) {
|
||||
fail(c, apperror.ErrForbidden)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
StrategyVersionID string `json:"strategy_version_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Exchange string `json:"exchange"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
InitialCash string `json:"initial_cash"`
|
||||
}
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
v, e := o.Backtests.CreateMarket(c.Request.Context(), currentUser(c), input.StrategyVersionID, input.Symbol, input.Exchange, input.Start, input.End, input.InitialCash, c.Writer.Header().Get("X-Request-ID"))
|
||||
if e != nil {
|
||||
fail(c, e)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusAccepted, v)
|
||||
})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func bindCandidates(r *gin.Engine, options IdentityOptions) {
|
||||
for _, admin := range []bool{true, false} {
|
||||
path, audience := "/api/v1", "app"
|
||||
if admin {
|
||||
path, audience = "/api/admin/v1", "admin"
|
||||
}
|
||||
group := r.Group(path, func(c *gin.Context) { authenticate(c, options, audience) })
|
||||
group.Use(func(c *gin.Context) {
|
||||
if options.Candidates == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
group.GET("/candidates", func(c *gin.Context) {
|
||||
limit := 100
|
||||
if raw := c.Query("limit"); raw != "" {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
fail(c, apperror.ErrInvalidInput)
|
||||
return
|
||||
}
|
||||
limit = value
|
||||
}
|
||||
result, err := options.Candidates.List(c.Request.Context(), currentUser(c), admin, c.Query("status"), limit)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
group.GET("/candidates/:id", func(c *gin.Context) {
|
||||
result, err := options.Candidates.Detail(c.Request.Context(), currentUser(c), c.Param("id"), admin)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
if !admin {
|
||||
continue
|
||||
}
|
||||
group.Use(func(c *gin.Context) {
|
||||
if !sameOrigin(c.Request) {
|
||||
fail(c, apperror.ErrForbidden)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
group.POST("/candidates", func(c *gin.Context) {
|
||||
var input model.CandidateDraftInput
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := options.Candidates.Create(c.Request.Context(), currentUser(c), "", c.Writer.Header().Get("X-Request-ID"), input, 0)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusCreated, result)
|
||||
})
|
||||
group.POST("/candidates/:id/revisions", func(c *gin.Context) {
|
||||
var input model.CandidateRevisionInput
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := options.Candidates.Create(c.Request.Context(), currentUser(c), c.Param("id"), c.Writer.Header().Get("X-Request-ID"), input.CandidateDraftInput, input.ExpectedVersion)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusCreated, result)
|
||||
})
|
||||
for endpoint, action := range map[string]string{"submit": "SUBMIT", "publish": "PUBLISH", "return": "RETURN", "withdraw": "WITHDRAW"} {
|
||||
group.POST("/candidates/:id/"+endpoint, func(c *gin.Context) {
|
||||
var input model.CandidateActionInput
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := options.Candidates.Transition(c.Request.Context(), currentUser(c), c.Param("id"), action, c.Writer.Header().Get("X-Request-ID"), input)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,15 @@ type LoginLimiter interface {
|
||||
Login(context.Context, string, string) (bool, error)
|
||||
}
|
||||
type IdentityOptions struct {
|
||||
Market *service.MarketService
|
||||
Backtests *service.BacktestService
|
||||
Auth *service.AuthService
|
||||
Providers *service.ProviderService
|
||||
Candidates *service.CandidateService
|
||||
Plans *service.TradePlanService
|
||||
Fills *service.TradeFillService
|
||||
Notifications *service.NotificationService
|
||||
Strategies *service.StrategyService
|
||||
Accounts *service.AccountService
|
||||
Limiter LoginLimiter
|
||||
DevelopmentLogin bool
|
||||
@@ -209,7 +217,11 @@ func sameOrigin(r *http.Request) bool {
|
||||
}
|
||||
|
||||
func decode(c *gin.Context, value any) bool {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
||||
return decodeLimit(c, value, 16<<10)
|
||||
}
|
||||
|
||||
func decodeLimit(c *gin.Context, value any, limit int64) bool {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
if !strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "application/json") {
|
||||
failure(c, http.StatusUnsupportedMediaType, "INVALID_CONTENT_TYPE", "请使用 application/json。", false)
|
||||
return false
|
||||
@@ -237,13 +249,25 @@ func failure(c *gin.Context, status int, code, message string, retryable bool) {
|
||||
func fail(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, apperror.ErrInvalidInput):
|
||||
failure(c, http.StatusUnprocessableEntity, "INVALID_INPUT", "请检查输入内容;资金最多两位小数,密码为 12–72 字节。", false)
|
||||
failure(c, http.StatusUnprocessableEntity, "INVALID_INPUT", "请检查输入字段、必填项与格式。", false)
|
||||
case errors.Is(err, apperror.ErrUnauthenticated):
|
||||
failure(c, http.StatusUnauthorized, "UNAUTHENTICATED", "登录信息无效或会话已过期。", false)
|
||||
case errors.Is(err, apperror.ErrForbidden):
|
||||
failure(c, http.StatusForbidden, "FORBIDDEN", "无权执行此操作。", false)
|
||||
case errors.Is(err, apperror.ErrInsufficientCash):
|
||||
failure(c, http.StatusConflict, "INSUFFICIENT_CASH", "可用现金不足,请检查其他计划保留的预算。", false)
|
||||
case errors.Is(err, apperror.ErrInsufficientQuantity):
|
||||
failure(c, http.StatusConflict, "INSUFFICIENT_SELLABLE_QUANTITY", "可卖数量不足;请检查模拟可卖时间与剩余持仓。", false)
|
||||
case errors.Is(err, apperror.ErrCandidateUnavailable):
|
||||
failure(c, http.StatusConflict, "CANDIDATE_UNAVAILABLE", "候选已失效或版本已更新,请重新查看依据。", false)
|
||||
case errors.Is(err, apperror.ErrPreviewExpired):
|
||||
failure(c, http.StatusConflict, "PREVIEW_EXPIRED", "预算预览已过期,请重新预览后确认。", false)
|
||||
case errors.Is(err, apperror.ErrChronology):
|
||||
failure(c, http.StatusConflict, "FILL_CHRONOLOGY", "请按成交发生时间顺序录入;更早记录需先纠正后续成交。", false)
|
||||
case errors.Is(err, apperror.ErrDependentFill):
|
||||
failure(c, http.StatusConflict, "DEPENDENT_FILL", "后续成交依赖此记录,请先冲正最新成交。", false)
|
||||
case errors.Is(err, apperror.ErrConflict):
|
||||
failure(c, http.StatusConflict, "CONFLICT", "该登录名或账户类型已存在。", false)
|
||||
failure(c, http.StatusConflict, "CONFLICT", "记录已存在或版本已更新,请刷新后重试。", false)
|
||||
case errors.Is(err, apperror.ErrNotFound):
|
||||
failure(c, http.StatusNotFound, "NOT_FOUND", "记录不存在。", false)
|
||||
default:
|
||||
|
||||
@@ -2,8 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -18,7 +17,7 @@ func TestAuthenticationAndCSRFBoundaries(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{Auth: auth, DevelopmentLogin: true})
|
||||
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), IdentityOptions{Auth: auth, DevelopmentLogin: true})
|
||||
for _, tc := range []struct {
|
||||
method, path, body, origin string
|
||||
status int
|
||||
@@ -43,7 +42,7 @@ func TestAuthenticationAndCSRFBoundaries(t *testing.T) {
|
||||
|
||||
func TestDevelopmentLoginDisabledByDefault(t *testing.T) {
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
||||
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), IdentityOptions{})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{}`)))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func bindMarket(r *gin.Engine, o IdentityOptions) {
|
||||
for _, audience := range []string{"admin", "app"} {
|
||||
prefix := "/api/v1"
|
||||
if audience == "admin" {
|
||||
prefix = "/api/admin/v1"
|
||||
}
|
||||
g := r.Group(prefix, func(c *gin.Context) { authenticate(c, o, audience) })
|
||||
g.Use(func(c *gin.Context) {
|
||||
if o.Market == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
g.GET("/instruments", func(c *gin.Context) {
|
||||
v, e := o.Market.List(c.Request.Context(), currentUser(c), c.Query("q"))
|
||||
if e != nil {
|
||||
fail(c, e)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, v)
|
||||
})
|
||||
g.GET("/market/history", func(c *gin.Context) {
|
||||
v, e := o.Market.History(c.Request.Context(), currentUser(c), c.Query("symbol"), c.Query("exchange"), c.Query("start"), c.Query("end"))
|
||||
if e != nil {
|
||||
fail(c, e)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, v)
|
||||
})
|
||||
if audience == "admin" {
|
||||
g.GET("/market/syncs", func(c *gin.Context) {
|
||||
v, e := o.Market.Tasks(c.Request.Context(), currentUser(c))
|
||||
if e != nil {
|
||||
fail(c, e)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, v)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func bindNotifications(r *gin.Engine, options IdentityOptions) {
|
||||
for _, admin := range []bool{true, false} {
|
||||
path, audience := "/api/v1", "app"
|
||||
if admin {
|
||||
path, audience = "/api/admin/v1", "admin"
|
||||
}
|
||||
group := r.Group(path, func(c *gin.Context) { authenticate(c, options, audience) })
|
||||
group.Use(func(c *gin.Context) {
|
||||
if options.Notifications == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
if admin {
|
||||
group.GET("/notification-tasks", func(c *gin.Context) {
|
||||
result, err := options.Notifications.Tasks(c.Request.Context(), currentUser(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
group.GET("/simulation-quotes", func(c *gin.Context) {
|
||||
result, err := options.Notifications.Quotes(c.Request.Context(), currentUser(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
group.POST("/simulation-quotes", func(c *gin.Context) {
|
||||
if !sameOrigin(c.Request) {
|
||||
fail(c, apperror.ErrForbidden)
|
||||
return
|
||||
}
|
||||
var input service.SimulationQuoteInput
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := options.Notifications.SaveQuote(c.Request.Context(), currentUser(c), input, c.Writer.Header().Get("X-Request-ID"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusCreated, result)
|
||||
})
|
||||
} else {
|
||||
group.GET("/notifications", func(c *gin.Context) {
|
||||
result, err := options.Notifications.List(c.Request.Context(), currentUser(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
group.POST("/notifications/:id/read", func(c *gin.Context) {
|
||||
var input struct{}
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
if err := options.Notifications.MarkRead(c.Request.Context(), currentUser(c), c.Param("id")); err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, gin.H{"read": true})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func bindProviders(r *gin.Engine, options IdentityOptions) {
|
||||
admin := r.Group("/api/admin/v1", func(c *gin.Context) { authenticate(c, options, "admin") })
|
||||
admin.Use(func(c *gin.Context) {
|
||||
if options.Providers == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
admin.GET("/providers", func(c *gin.Context) {
|
||||
list, err := options.Providers.List(c.Request.Context(), currentUser(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, list)
|
||||
})
|
||||
admin.PUT("/providers/:key", func(c *gin.Context) {
|
||||
if !sameOrigin(c.Request) {
|
||||
fail(c, apperror.ErrForbidden)
|
||||
return
|
||||
}
|
||||
var input service.ProviderInput
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
config, err := options.Providers.Save(c.Request.Context(), currentUser(c), c.Param("key"), c.Writer.Header().Get("X-Request-ID"), input)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, config)
|
||||
})
|
||||
admin.GET("/audit-events", func(c *gin.Context) {
|
||||
list, err := options.Providers.Audit(c.Request.Context(), currentUser(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, list)
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
)
|
||||
|
||||
func NewRouter(health *service.HealthService, logger *slog.Logger, identity IdentityOptions) http.Handler {
|
||||
func NewRouter(health *service.HealthService, logger *zap.Logger, identity IdentityOptions) http.Handler {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
@@ -29,10 +29,27 @@ func NewRouter(health *service.HealthService, logger *slog.Logger, identity Iden
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
logger.Error("request panic", "request_id", requestID)
|
||||
logger.Error("request panic", zap.String("request_id", requestID))
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Internal server error", "request_id": requestID}})
|
||||
}
|
||||
logger.Info("http request", "method", c.Request.Method, "route", c.FullPath(), "status", c.Writer.Status(), "duration_ms", time.Since(start).Milliseconds(), "request_id", requestID)
|
||||
route := c.FullPath()
|
||||
if route == "" {
|
||||
route = "/<unmatched>"
|
||||
if c.Request.URL.Path == "/" {
|
||||
route = "/"
|
||||
} else if strings.HasPrefix(c.Request.URL.Path, "/assets/") {
|
||||
route = "/assets/*"
|
||||
}
|
||||
}
|
||||
fields := []zap.Field{zap.String("method", c.Request.Method), zap.String("route", route), zap.Int("status", c.Writer.Status()), zap.Int64("duration_ms", time.Since(start).Milliseconds()), zap.String("request_id", requestID)}
|
||||
switch {
|
||||
case c.Writer.Status() >= 500:
|
||||
logger.Error("HTTP request", fields...)
|
||||
case c.Writer.Status() >= 400:
|
||||
logger.Warn("HTTP request", fields...)
|
||||
default:
|
||||
logger.Info("HTTP request", fields...)
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
})
|
||||
@@ -48,6 +65,14 @@ func NewRouter(health *service.HealthService, logger *slog.Logger, identity Iden
|
||||
r.GET("/readyz", check)
|
||||
r.GET("/api/v1/system/health", check)
|
||||
bindIdentity(r, identity)
|
||||
bindProviders(r, identity)
|
||||
bindCandidates(r, identity)
|
||||
bindTradePlans(r, identity)
|
||||
bindTradeFills(r, identity)
|
||||
bindNotifications(r, identity)
|
||||
bindStrategies(r, identity)
|
||||
bindBacktests(r, identity)
|
||||
bindMarket(r, identity)
|
||||
console := adminui.Handler()
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if !strings.HasPrefix(c.Request.URL.Path, "/api/") && c.Request.URL.Path != "/api" {
|
||||
|
||||
@@ -3,8 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -16,7 +15,7 @@ import (
|
||||
func TestLivenessAndReadinessAreIndependent(t *testing.T) {
|
||||
secretError := "password=secret-internal-detail"
|
||||
health := service.NewHealthService(nil, func(context.Context) error { return errors.New(secretError) })
|
||||
router := NewRouter(health, slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
||||
router := NewRouter(health, zap.NewNop(), IdentityOptions{})
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
status int
|
||||
@@ -37,7 +36,7 @@ func TestLivenessAndReadinessAreIndependent(t *testing.T) {
|
||||
|
||||
func TestReadyWhenBothDependenciesRespond(t *testing.T) {
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
||||
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), IdentityOptions{})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -47,7 +46,7 @@ func TestReadyWhenBothDependenciesRespond(t *testing.T) {
|
||||
|
||||
func TestEmbeddedConsoleAndAPIBoundary(t *testing.T) {
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
||||
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), IdentityOptions{})
|
||||
for _, path := range []string{"/", "/research/candidates"} {
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func bindStrategies(r *gin.Engine, options IdentityOptions) {
|
||||
group := r.Group("/api/admin/v1", func(c *gin.Context) { authenticate(c, options, "admin") })
|
||||
group.Use(func(c *gin.Context) {
|
||||
if options.Strategies == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
group.GET("/strategies", func(c *gin.Context) {
|
||||
result, err := options.Strategies.List(c.Request.Context(), currentUser(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
group.GET("/strategies/:id", func(c *gin.Context) {
|
||||
result, err := options.Strategies.Detail(c.Request.Context(), currentUser(c), c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
group.Use(func(c *gin.Context) {
|
||||
if !sameOrigin(c.Request) {
|
||||
fail(c, apperror.ErrForbidden)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
for _, path := range []string{"/strategies", "/strategies/:id/versions"} {
|
||||
group.POST(path, func(c *gin.Context) {
|
||||
var input service.StrategyInput
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := options.Strategies.Save(c.Request.Context(), currentUser(c), c.Param("id"), input, c.Writer.Header().Get("X-Request-ID"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusCreated, result)
|
||||
})
|
||||
}
|
||||
group.POST("/strategies/:id/deploy", func(c *gin.Context) {
|
||||
var input struct {
|
||||
VersionID string `json:"version_id"`
|
||||
Status string `json:"status"`
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := options.Strategies.Deploy(c.Request.Context(), currentUser(c), c.Param("id"), input.VersionID, input.Status, input.ExpectedVersion, input.Reason, c.Writer.Header().Get("X-Request-ID"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func bindTradeFills(r *gin.Engine, options IdentityOptions) {
|
||||
group := r.Group("/api/v1", func(c *gin.Context) { authenticate(c, options, "app") })
|
||||
group.Use(func(c *gin.Context) {
|
||||
if options.Fills == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
group.POST("/fills", func(c *gin.Context) {
|
||||
var input service.FillInput
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := options.Fills.Record(c.Request.Context(), currentUser(c), input, c.Writer.Header().Get("X-Request-ID"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusCreated, result)
|
||||
})
|
||||
group.POST("/fills/:id/reverse", func(c *gin.Context) {
|
||||
var input struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := options.Fills.Reverse(c.Request.Context(), currentUser(c), c.Param("id"), input.Reason, c.Writer.Header().Get("X-Request-ID"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
group.GET("/accounts/:id/fills", func(c *gin.Context) {
|
||||
result, err := options.Fills.List(c.Request.Context(), currentUser(c), c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
group.GET("/accounts/:id/portfolio", func(c *gin.Context) {
|
||||
result, err := options.Fills.Portfolio(c.Request.Context(), currentUser(c), c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func bindTradePlans(r *gin.Engine, options IdentityOptions) {
|
||||
group := r.Group("/api/v1", func(c *gin.Context) { authenticate(c, options, "app") })
|
||||
group.Use(func(c *gin.Context) {
|
||||
if options.Plans == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
group.POST("/plan-previews", func(c *gin.Context) {
|
||||
var input service.PlanPreviewInput
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := options.Plans.Preview(c.Request.Context(), currentUser(c), input)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusCreated, result)
|
||||
})
|
||||
group.POST("/plan-previews/:id/confirm", func(c *gin.Context) {
|
||||
var input struct{}
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := options.Plans.Confirm(c.Request.Context(), currentUser(c), c.Param("id"), c.Writer.Header().Get("X-Request-ID"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
group.GET("/plans", func(c *gin.Context) {
|
||||
result, err := options.Plans.List(c.Request.Context(), currentUser(c))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
group.POST("/plans/:id/cancel", func(c *gin.Context) {
|
||||
var input struct {
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
}
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := options.Plans.Cancel(c.Request.Context(), currentUser(c), c.Param("id"), input.ExpectedVersion, c.Writer.Header().Get("X-Request-ID"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
group.GET("/accounts/:id/budget", func(c *gin.Context) {
|
||||
result, err := options.Plans.Budget(c.Request.Context(), currentUser(c), c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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 testAdminCredentialChange(t *testing.T, ctx context.Context, pool *pgxpool.Pool, auth *service.AuthService) {
|
||||
t.Helper()
|
||||
repo := repository.NewIdentityRepository(pool)
|
||||
previous, credential, err := repo.FindCredential(ctx, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSession, err := auth.Login(ctx, "admin", "an-admin-test-password", "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
password := "test1234"
|
||||
updater := service.NewAdminCredentialService(repo, true)
|
||||
if _, err := service.NewAdminCredentialService(repo, false).Set(ctx, "admin", "operator", password); !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("production credential setup allowed")
|
||||
}
|
||||
if _, err := updater.Set(ctx, "alice", "operator", password); !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("ordinary user promoted by credential command")
|
||||
}
|
||||
if _, err := updater.Set(ctx, "admin", "bob", password); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("duplicate login accepted")
|
||||
}
|
||||
if _, err := auth.Authenticate(ctx, oldSession.Token, "admin"); err != nil {
|
||||
t.Fatal("failed update revoked existing session")
|
||||
}
|
||||
userID, err := updater.Set(ctx, "admin", "operator", password)
|
||||
if err != nil || userID != previous.ID {
|
||||
t.Fatal("administrator identity was not preserved")
|
||||
}
|
||||
if _, err := auth.Authenticate(ctx, oldSession.Token, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("old session survived password change")
|
||||
}
|
||||
if _, err := auth.Login(ctx, "admin", "an-admin-test-password", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("old login survived update")
|
||||
}
|
||||
login, err := auth.Login(ctx, "operator", password, "admin")
|
||||
if err != nil || login.User.ID != previous.ID {
|
||||
t.Fatal("new credentials failed")
|
||||
}
|
||||
_, updated, err := repo.FindCredential(ctx, "operator")
|
||||
if err != nil || updated.PasswordHash == password || updated.PasswordHash == credential.PasswordHash {
|
||||
t.Fatal("new password was not hashed")
|
||||
}
|
||||
stale := model.Session{ID: uuid.NewString(), UserID: previous.ID, TokenHash: service.TokenHash(uuid.NewString()), Audience: "admin", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
if err := repo.CreateSession(ctx, stale, credential.PasswordHash); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("stale password verification created a new session")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/handler"
|
||||
"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 testCandidateLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Pool, auth *service.AuthService, admin, user model.User) {
|
||||
t.Helper()
|
||||
repo := repository.NewCandidateRepository(pool)
|
||||
svc := service.NewCandidateService(repo, true)
|
||||
input := model.CandidateDraftInput{Content: model.CandidateContent{Symbol: "600000", Exchange: "SSE", Name: "测试证券", StrategyCode: "trend_following", Title: "测试候选", Thesis: "可核验测试依据", Evidence: []model.CandidateEvidence{{SourceURL: "https://evidence.example/report", Locator: "测试段落", Summary: "测试摘要"}}, CounterEvidence: "存在风险", Invalidation: "条件失效", ReferencePlan: model.CandidateReferencePlan{EntryLow: "10.00", EntryHigh: "11.00", StopLoss: "9.00", TargetPrice: "13.00"}}, ObservedAt: time.Now().UTC(), ValidUntil: time.Now().UTC().Add(time.Hour)}
|
||||
create := func(source string, expected int64) model.CandidateRevision {
|
||||
r, err := svc.Create(ctx, admin, source, uuid.NewString(), input, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
act := func(r model.CandidateRevision, action string) model.CandidateRevision {
|
||||
next, err := svc.Transition(ctx, admin, r.ID, action, uuid.NewString(), model.CandidateActionInput{ExpectedVersion: r.Version, Reason: "集成测试审核意见"})
|
||||
if err != nil {
|
||||
t.Fatalf("transition %s: %v", action, err)
|
||||
}
|
||||
return next
|
||||
}
|
||||
list := func() []model.CandidateRevision {
|
||||
r, err := svc.List(ctx, user, false, "", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
draft := create("", 0)
|
||||
if len(list()) != 0 {
|
||||
t.Fatal("draft leaked into app feed")
|
||||
}
|
||||
if _, err := svc.Detail(ctx, user, draft.ID, false); !errors.Is(err, apperror.ErrNotFound) {
|
||||
t.Fatal("app read unpublished revision")
|
||||
}
|
||||
if _, err := svc.Create(ctx, user, "", uuid.NewString(), input, 0); !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("user created candidate")
|
||||
}
|
||||
if _, err := service.NewCandidateService(repo, false).List(ctx, user, false, "", 100); !errors.Is(err, apperror.ErrUnavailable) {
|
||||
t.Fatal("simulation capability exposed in production")
|
||||
}
|
||||
if _, err := svc.Transition(ctx, admin, draft.ID, "PUBLISH", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: draft.Version, Reason: "invalid"}); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("draft published without submission")
|
||||
}
|
||||
pending := act(draft, "SUBMIT")
|
||||
if _, err := svc.Create(ctx, admin, pending.ID, uuid.NewString(), input, pending.Version); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("pending review overwritten")
|
||||
}
|
||||
returned := act(pending, "RETURN")
|
||||
replacement := create(returned.ID, returned.Version)
|
||||
if replacement.CandidateID != draft.CandidateID || replacement.Revision != 2 {
|
||||
t.Fatal("revision history lost")
|
||||
}
|
||||
pending = act(replacement, "SUBMIT")
|
||||
// Two concurrent reviewers cannot both commit the same input version.
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := svc.Transition(ctx, admin, pending.ID, "PUBLISH", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: pending.Version, Reason: "并发发布测试"})
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
success, conflict := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
success++
|
||||
} else if errors.Is(err, apperror.ErrConflict) {
|
||||
conflict++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 1 {
|
||||
t.Fatal("publication CAS failed")
|
||||
}
|
||||
published, err := svc.Detail(ctx, admin, pending.ID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list()) != 1 || !published.IsActivePublication || len(published.Reviews) != 2 {
|
||||
t.Fatal("publication missing or duplicate reviews")
|
||||
}
|
||||
appDetail, err := svc.Detail(ctx, user, pending.ID, false)
|
||||
if err != nil || len(appDetail.Reviews) != 0 {
|
||||
t.Fatal("internal reviews leaked")
|
||||
}
|
||||
source := published.CandidateRevision
|
||||
input.Content.Thesis = "第二次发布的新内容"
|
||||
next := create(source.ID, source.Version)
|
||||
if len(list()) != 1 || list()[0].ID != source.ID {
|
||||
t.Fatal("new draft hid current publication")
|
||||
}
|
||||
next = act(act(next, "SUBMIT"), "PUBLISH")
|
||||
if len(list()) != 1 || list()[0].ID != next.ID {
|
||||
t.Fatal("superseded publication remained active")
|
||||
}
|
||||
old, err := svc.Detail(ctx, user, source.ID, false)
|
||||
if err != nil || old.IsActivePublication || old.Snapshot.Thesis == input.Content.Thesis {
|
||||
t.Fatal("old content overwritten")
|
||||
}
|
||||
testTradePlans(t, ctx, pool, user, next)
|
||||
// Inject a durable-event write failure; publication, review and audit must roll back.
|
||||
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_outbox_event ADD CONSTRAINT veyra_test_event_failure CHECK(event_type!='candidate.withdrawn') NOT VALID`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Transition(ctx, admin, next.ID, "WITHDRAW", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: next.Version, Reason: "事务回滚测试"}); err == nil {
|
||||
t.Fatal("injected outbox error did not fail")
|
||||
}
|
||||
after, err := svc.Detail(ctx, admin, next.ID, true)
|
||||
if err != nil || after.Version != next.Version || after.Status != "PUBLISHED" || len(after.Reviews) != 2 {
|
||||
t.Fatal("failed event left partial publication")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_outbox_event DROP CONSTRAINT veyra_test_event_failure`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withdrawn := act(next, "WITHDRAW")
|
||||
plans, err := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true).List(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range plans {
|
||||
if p.CandidateRevisionID == next.ID && p.Status != "CANCELLED" && (p.Status != "INVALIDATED" || p.ReservedMinor != 0) {
|
||||
t.Fatal("withdrawal did not invalidate plan and release budget")
|
||||
}
|
||||
}
|
||||
if len(list()) != 0 {
|
||||
t.Fatal("withdrawn publication visible in feed")
|
||||
}
|
||||
historical, err := svc.Detail(ctx, user, withdrawn.ID, false)
|
||||
if err != nil || historical.Status != "WITHDRAWN" || historical.IsActivePublication {
|
||||
t.Fatal("withdrawal history unavailable")
|
||||
}
|
||||
var eventCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_outbox_event WHERE aggregate_type='candidate_revision'`).Scan(&eventCount); err != nil || eventCount != 4 {
|
||||
t.Fatalf("outbox facts: count=%d err=%v", eventCount, err)
|
||||
}
|
||||
// Expired published candidates leave the live feed without deleting history.
|
||||
expires := act(act(create("", 0), "SUBMIT"), "PUBLISH")
|
||||
if _, err := pool.Exec(ctx, `UPDATE veyra_candidate_revision SET observed_at=now()-interval '2 hours',valid_until=now()-interval '1 hour' WHERE id=$1`, expires.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list()) != 0 {
|
||||
t.Fatal("expired publication visible in feed")
|
||||
}
|
||||
// Real HTTP adapters enforce cookie audience, origin and unpublished visibility.
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := handler.NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), handler.IdentityOptions{Auth: auth, Candidates: svc, Limiter: allowLogin{}, DevelopmentLogin: true})
|
||||
adminLogin, err := auth.Login(ctx, "admin", "an-admin-test-password", "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userLogin, err := auth.Login(ctx, "alice", "a-test-only-password", "app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := func(method, path, body, origin, token string, adminAudience bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, "http://example.com"+path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", origin)
|
||||
if adminAudience {
|
||||
req.AddCookie(&http.Cookie{Name: "veyra_admin_session", Value: token})
|
||||
} else {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
if r := call("GET", "/api/v1/candidates/"+draft.ID, "", "", userLogin.Token, false); r.Code != 404 {
|
||||
t.Fatal("HTTP draft disclosure")
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
if r := call("POST", "/api/admin/v1/candidates", string(body), "http://attacker.example", adminLogin.Token, true); r.Code != 403 {
|
||||
t.Fatal("cross origin create accepted")
|
||||
}
|
||||
if r := call("GET", "/api/admin/v1/candidates", "", "", userLogin.Token, true); r.Code != 401 {
|
||||
t.Fatal("app token accepted as admin cookie")
|
||||
}
|
||||
r := call("POST", "/api/admin/v1/candidates", string(body), "http://example.com", adminLogin.Token, true)
|
||||
if r.Code != 201 || !bytes.Contains(r.Body.Bytes(), []byte(`"status":"DRAFT"`)) {
|
||||
t.Fatalf("HTTP create: %d %s", r.Code, r.Body.String())
|
||||
}
|
||||
if r := call("GET", "/api/v1/candidates?limit=101", "", "", userLogin.Token, false); r.Code != 422 {
|
||||
t.Fatal("unbounded list accepted")
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,7 @@ package integration
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -19,6 +18,7 @@ import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/database"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/handler"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/secret"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
||||
if dsn == "" {
|
||||
t.Skip("set VEYRA_TEST_DATABASE_URL for real database integration")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
admin, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
@@ -127,11 +127,15 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
||||
if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("expired session remained valid")
|
||||
}
|
||||
if _, err := auth.ProvisionUser(ctx, "admin", "an-admin-test-password", "Admin", "ADMIN"); err != nil {
|
||||
adminUser, err := auth.ProvisionUser(ctx, "admin", "an-admin-test-password", "Admin", "ADMIN")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testProviderLifecycle(t, ctx, pool, adminUser, u1)
|
||||
vault, _ := secret.NewVault("")
|
||||
providers := service.NewProviderService(repository.NewProviderRepository(pool), vault)
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := handler.NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), handler.IdentityOptions{Auth: auth, Accounts: accounts, Limiter: allowLogin{}, DevelopmentLogin: true})
|
||||
router := handler.NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), handler.IdentityOptions{Auth: auth, Providers: providers, Accounts: accounts, Limiter: allowLogin{}, DevelopmentLogin: true})
|
||||
call := func(method, path, body, origin string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, "http://example.com"+path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
@@ -162,6 +166,23 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
||||
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusOK {
|
||||
t.Fatal("cookie authentication failed")
|
||||
}
|
||||
|
||||
if response := call(http.MethodGet, "/api/admin/v1/providers", "", "", nil); response.Code != http.StatusUnauthorized {
|
||||
t.Fatal("unauthenticated configuration read allowed")
|
||||
}
|
||||
if response := call(http.MethodGet, "/api/admin/v1/providers", "", "", cookie); response.Code != http.StatusOK {
|
||||
t.Fatal("admin configuration read failed")
|
||||
}
|
||||
body := `{"name":"","base_url":"","model_name":"","enabled":false,"timeout_seconds":15,"clear_api_key":false,"expected_version":0}`
|
||||
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://attacker.example", cookie); response.Code != http.StatusForbidden {
|
||||
t.Fatal("cross-origin configuration write allowed")
|
||||
}
|
||||
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://example.com", cookie); response.Code != http.StatusOK {
|
||||
t.Fatal("empty market configuration write failed")
|
||||
}
|
||||
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://example.com", cookie); response.Code != http.StatusConflict {
|
||||
t.Fatal("HTTP stale version accepted")
|
||||
}
|
||||
if response := call(http.MethodPost, "/api/admin/v1/auth/logout", "", "http://attacker.example", cookie); response.Code != http.StatusForbidden {
|
||||
t.Fatal("cross-origin logout allowed")
|
||||
}
|
||||
@@ -174,6 +195,10 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
||||
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusUnauthorized {
|
||||
t.Fatal("revoked admin cookie remained valid")
|
||||
}
|
||||
testCandidateLifecycle(t, ctx, pool, auth, adminUser, u1)
|
||||
testStrategyBacktest(t, ctx, pool, adminUser, u1)
|
||||
testTradeLedger(t, ctx, pool, auth)
|
||||
testAdminCredentialChange(t, ctx, pool, auth)
|
||||
}
|
||||
|
||||
type allowLogin struct{}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testNotifications(t *testing.T, ctx context.Context, pool *pgxpool.Pool, user model.User, candidate model.CandidateRevision, account string) {
|
||||
t.Helper()
|
||||
repo := repository.NewNotificationRepository(pool)
|
||||
svc := service.NewNotificationService(repo, true)
|
||||
var admin model.User
|
||||
if err := pool.QueryRow(ctx, `SELECT id,role,status FROM veyra_user WHERE role='ADMIN' LIMIT 1`).Scan(&admin.ID, &admin.Role, &admin.Status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := func() {
|
||||
for i := 0; i < 50; i++ {
|
||||
worked, err := repo.ProcessNext(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !worked {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("notification queue did not drain")
|
||||
}
|
||||
var cashBefore, fillBefore int64
|
||||
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_minor),0)::bigint FROM veyra_cash_entry WHERE account_id=$1`, account).Scan(&cashBefore); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_trade_fill WHERE account_id=$1`, account).Scan(&fillBefore); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quote := service.SimulationQuoteInput{Symbol: candidate.Snapshot.Symbol, Exchange: candidate.Snapshot.Exchange, Price: "10.50", ObservedAt: time.Now().UTC()}
|
||||
if _, err := svc.SaveQuote(ctx, user, quote, uuid.NewString()); !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("user wrote quote")
|
||||
}
|
||||
q, err := svc.SaveQuote(ctx, admin, quote, uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process()
|
||||
notices, err := svc.List(ctx, user)
|
||||
if err != nil || len(notices) != 1 {
|
||||
t.Fatalf("plan reminder missing: %d %v", len(notices), err)
|
||||
}
|
||||
quote.ObservedAt = time.Now().UTC()
|
||||
if _, err := svc.SaveQuote(ctx, admin, quote, uuid.NewString()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process()
|
||||
notices, err = svc.List(ctx, user)
|
||||
if err != nil || len(notices) != 1 {
|
||||
t.Fatal("repeated price produced duplicate reminder")
|
||||
}
|
||||
var cashAfter, fillAfter int64
|
||||
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_minor),0)::bigint FROM veyra_cash_entry WHERE account_id=$1`, account).Scan(&cashAfter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_trade_fill WHERE account_id=$1`, account).Scan(&fillAfter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cashBefore != cashAfter || fillBefore != fillAfter {
|
||||
t.Fatal("reminder altered ledger")
|
||||
}
|
||||
stranger := user
|
||||
stranger.ID = uuid.NewString()
|
||||
if err := svc.MarkRead(ctx, stranger, notices[0].ID); !errors.Is(err, apperror.ErrNotFound) {
|
||||
t.Fatal("cross user notification write")
|
||||
}
|
||||
if err := svc.MarkRead(ctx, user, notices[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks, err := svc.Tasks(ctx, admin)
|
||||
if err != nil || len(tasks) == 0 {
|
||||
t.Fatal("task audit missing")
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.ProcessedAt == nil {
|
||||
t.Fatal("queue event did not commit processed flag")
|
||||
}
|
||||
}
|
||||
// A second plan must not trigger on an expired or superseded quote event.
|
||||
plans := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true)
|
||||
preview, err := plans.Preview(ctx, user, service.PlanPreviewInput{AccountID: account, CandidateRevisionID: candidate.ID, ExpectedCandidateVersion: candidate.Version, Quantity: "100", FeeBudget: "0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := plans.Confirm(ctx, user, preview.ID, uuid.NewString()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quote.ObservedAt = time.Now().UTC()
|
||||
q, err = svc.SaveQuote(ctx, admin, quote, uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE veyra_quote_snapshot SET observed_at=now()-interval '2 minutes' WHERE id=$1`, q.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process()
|
||||
notices, err = svc.List(ctx, user)
|
||||
if err != nil || len(notices) != 1 {
|
||||
t.Fatal("stale quote triggered plan")
|
||||
}
|
||||
quote.ObservedAt = time.Now().UTC()
|
||||
if _, err := svc.SaveQuote(ctx, admin, quote, uuid.NewString()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process()
|
||||
notices, err = svc.List(ctx, user)
|
||||
if err != nil || len(notices) != 2 {
|
||||
t.Fatal("fresh second-plan reminder failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"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/secret"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func testProviderLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Pool, actor, ordinary model.User) {
|
||||
t.Helper()
|
||||
vault, err := secret.NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{9}, 32)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := repository.NewProviderRepository(pool)
|
||||
svc := service.NewProviderService(repo, vault)
|
||||
configs, err := svc.List(ctx, actor)
|
||||
if err != nil || len(configs) != 0 {
|
||||
t.Fatalf("configuration must start empty: %v", err)
|
||||
}
|
||||
input := service.ProviderInput{TimeoutSeconds: 15}
|
||||
config, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||
if err != nil || config.Enabled || config.BaseURL != "" || config.LastTestStatus != "NOT_CONFIGURED" || config.WireAPI != "responses" || !config.DisableResponseStorage || !config.RequiresOpenAIAuth {
|
||||
t.Fatalf("blank configuration: %v", err)
|
||||
}
|
||||
if _, err := svc.List(ctx, ordinary); !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("ordinary user accessed provider configuration")
|
||||
}
|
||||
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("stale configuration accepted")
|
||||
}
|
||||
token := "test-private-provider-key"
|
||||
input = service.ProviderInput{Name: "configured vendor", BaseURL: "https://provider.example/v1", ModelName: "configured-model", ModelCatalogURL: "https://provider.example/v1/models", TimeoutSeconds: 15, Enabled: true, APIKey: &token, ExpectedVersion: config.Version}
|
||||
config, err = svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := json.Marshal(config)
|
||||
if bytes.Contains(data, []byte(token)) || bytes.Contains(data, []byte("encrypted_api_key")) {
|
||||
t.Fatal("API exposes credential")
|
||||
}
|
||||
loaded, err := repo.Get(ctx, "model_primary")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := vault.Decrypt(loaded.EncryptedAPIKey, "model_primary")
|
||||
if err != nil || value != token {
|
||||
t.Fatal("credential was not persisted encrypted")
|
||||
}
|
||||
input.ExpectedVersion = config.Version
|
||||
input.ModelCatalogURL = "https://another.example/models"
|
||||
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||
t.Fatal("cross-origin model catalog accepted")
|
||||
}
|
||||
input.ModelCatalogURL = "https://provider.example/v1/models"
|
||||
input.WireAPI = "unknown"
|
||||
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||
t.Fatal("unsupported protocol accepted")
|
||||
}
|
||||
input.WireAPI = "responses"
|
||||
input.ExpectedVersion = config.Version
|
||||
input.APIKey = nil
|
||||
config, err = svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||
if err != nil || !config.HasAPIKey {
|
||||
t.Fatal("omitted key did not preserve existing credential")
|
||||
}
|
||||
input.ExpectedVersion = config.Version
|
||||
input.BaseURL = "https://different.example/v1"
|
||||
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||
t.Fatal("changed endpoint reused existing secret")
|
||||
}
|
||||
input.BaseURL = "https://provider.example/v1?api_key=secret"
|
||||
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||
t.Fatal("query credentials accepted")
|
||||
}
|
||||
input.BaseURL = config.BaseURL
|
||||
input.Enabled = false
|
||||
input.ClearAPIKey = true
|
||||
disabled := false
|
||||
input.DisableResponseStorage = &disabled
|
||||
input.RequiresOpenAIAuth = &disabled
|
||||
config, err = svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||
if err != nil || config.HasAPIKey || config.DisableResponseStorage || config.RequiresOpenAIAuth {
|
||||
t.Fatal("key clear failed")
|
||||
}
|
||||
input.DisableResponseStorage = nil
|
||||
input.RequiresOpenAIAuth = nil
|
||||
// Two writers of the same version must produce one successor and one conflict.
|
||||
input.ExpectedVersion = config.Version
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
succeeded, conflicted := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
succeeded++
|
||||
} else if errors.Is(err, apperror.ErrConflict) {
|
||||
conflicted++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
latest, err := repo.Get(ctx, "model_primary")
|
||||
if err != nil || latest.DisableResponseStorage || latest.RequiresOpenAIAuth {
|
||||
t.Fatal("omitted flags did not preserve configuration")
|
||||
}
|
||||
if succeeded != 1 || conflicted != 1 {
|
||||
t.Fatal("concurrent version protection failed")
|
||||
}
|
||||
var snapshots string
|
||||
var historyCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*),string_agg(snapshot::text,'') FROM veyra_provider_config_history`).Scan(&historyCount, &snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if historyCount != 5 || strings.Contains(snapshots, token) {
|
||||
t.Fatal("history count or credential sanitization failed")
|
||||
}
|
||||
events, err := svc.Audit(ctx, actor)
|
||||
if err != nil || len(events) != 5 {
|
||||
t.Fatalf("atomic audit: %v", err)
|
||||
}
|
||||
encoded, _ := json.Marshal(events)
|
||||
if bytes.Contains(encoded, []byte(token)) {
|
||||
t.Fatal("audit exposed credential")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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/research"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testStrategyBacktest(t *testing.T, ctx context.Context, pool *pgxpool.Pool, admin, user model.User) {
|
||||
t.Helper()
|
||||
strategies := service.NewStrategyService(repository.NewStrategyRepository(pool), true)
|
||||
input := service.StrategyInput{Code: "sma_research", Name: "均线研究", Description: "fixture parameters", Parameters: model.StrategyParameters{ShortWindow: 2, LongWindow: 3, FeeBPS: 10, SlippageBPS: 0, LotSize: 100}}
|
||||
if _, e := strategies.Save(ctx, user, "", input, uuid.NewString()); !errors.Is(e, apperror.ErrForbidden) {
|
||||
t.Fatal("ordinary user can edit research strategy")
|
||||
}
|
||||
created, e := strategies.Save(ctx, admin, "", input, uuid.NewString())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
deployed, e := strategies.Deploy(ctx, admin, created.ID, created.Versions[0].ID, "ACTIVE", created.Version, "研究选用", uuid.NewString())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
input.ExpectedVersion = deployed.Version
|
||||
input.Parameters.ShortWindow = 3
|
||||
input.Parameters.LongWindow = 4
|
||||
revised, e := strategies.Save(ctx, admin, created.ID, input, uuid.NewString())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(revised.Versions) != 2 || *revised.ActiveVersionID != created.Versions[0].ID || revised.Versions[1].Parameters.ShortWindow != 2 {
|
||||
t.Fatal("revision changed selected or immutable parameters")
|
||||
}
|
||||
if _, e := strategies.Save(ctx, admin, created.ID, input, uuid.NewString()); !errors.Is(e, apperror.ErrConflict) {
|
||||
t.Fatal("stale strategy edit succeeded")
|
||||
}
|
||||
if _, e := pool.Exec(ctx, `UPDATE veyra_strategy_version SET description='overwrite' WHERE id=$1`, created.Versions[0].ID); e == nil {
|
||||
t.Fatal("strategy parameters can be overwritten")
|
||||
}
|
||||
repo := repository.NewBacktestRepository(pool)
|
||||
svc := service.NewBacktestService(repo, research.New("python3"), true)
|
||||
bars := []model.HistoricalBar{}
|
||||
for i, v := range []string{"10.00", "9.00", "8.00", "20.00", "21.00", "7.00", "6.00", "5.00"} {
|
||||
bars = append(bars, model.HistoricalBar{Date: fmt.Sprintf("2025-01-%02d", i+1), Open: v, High: v, Low: v, Close: v})
|
||||
}
|
||||
bi := model.BacktestInput{StrategyVersionID: created.Versions[0].ID, DatasetName: "integration fixture", Symbol: "600000", InitialCash: "10000.00", Bars: bars}
|
||||
bad := bi
|
||||
bad.Bars = append([]model.HistoricalBar(nil), bars...)
|
||||
bad.Bars[1].Date = bad.Bars[0].Date
|
||||
if _, e := svc.Create(ctx, admin, bad, uuid.NewString()); !errors.Is(e, apperror.ErrInvalidInput) {
|
||||
t.Fatal("duplicate day allowed")
|
||||
}
|
||||
job, e := svc.Create(ctx, admin, bi, uuid.NewString())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var snapshot model.ResearchInput
|
||||
if json.Unmarshal(job.Input, &snapshot) != nil || snapshot.Parameters.ShortWindow != 2 {
|
||||
t.Fatal("backtest lost immutable version parameters")
|
||||
}
|
||||
claim, e := repo.Claim(ctx)
|
||||
if e != nil || claim.ID != job.ID || claim.LeaseID == nil {
|
||||
t.Fatalf("claim: %v", e)
|
||||
}
|
||||
if _, e := repo.Claim(ctx); !errors.Is(e, apperror.ErrNotFound) {
|
||||
t.Fatal("active lease claimed twice")
|
||||
}
|
||||
if _, e := pool.Exec(ctx, `UPDATE veyra_backtest_run SET lease_expires_at=now()-interval '1 second' WHERE id=$1`, job.ID); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
renewed, e := repo.Claim(ctx)
|
||||
if e != nil || *renewed.LeaseID == *claim.LeaseID {
|
||||
t.Fatal("expired job cannot recover")
|
||||
}
|
||||
result, e := research.New("python3").Execute(ctx, renewed.Input)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := repo.Finish(ctx, claim, result, ""); !errors.Is(e, apperror.ErrConflict) {
|
||||
t.Fatal("stale worker can overwrite result")
|
||||
}
|
||||
if e := repo.Finish(ctx, renewed, result, ""); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
done, e := svc.Detail(ctx, admin, job.ID)
|
||||
if e != nil || done.Status != "COMPLETED" || done.FinishedAt == nil || len(done.Result) == 0 {
|
||||
t.Fatalf("completed job: %v", e)
|
||||
}
|
||||
if _, e := pool.Exec(ctx, `UPDATE veyra_backtest_run SET result='{}'::jsonb WHERE id=$1`, job.ID); e == nil {
|
||||
t.Fatal("completed result can be overwritten")
|
||||
}
|
||||
if _, e := svc.Detail(ctx, user, job.ID); !errors.Is(e, apperror.ErrForbidden) {
|
||||
t.Fatal("app user can access research inputs")
|
||||
}
|
||||
failed, e := svc.Create(ctx, admin, bi, uuid.NewString())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
failing := service.NewBacktestService(repo, research.New("/missing/python"), true)
|
||||
if _, e := failing.ProcessNext(ctx); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
failed, e = svc.Detail(ctx, admin, failed.ID)
|
||||
if e != nil || failed.Status != "FAILED" || failed.Result != nil || failed.Failure == "" {
|
||||
t.Fatal("execution failure presented a result")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testTradeLedger(t *testing.T, ctx context.Context, pool *pgxpool.Pool, auth *service.AuthService) {
|
||||
t.Helper()
|
||||
user, err := auth.ProvisionUser(ctx, "ledger_user", "ledger-test-password", "Ledger", "USER")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cash := "10000.00"
|
||||
account, err := service.NewAccountService(repository.NewAccountRepository(pool)).Create(ctx, user.ID, "simulation", "ledger", &cash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := service.NewTradeFillService(repository.NewTradeFillRepository(pool), true)
|
||||
now := time.Now().UTC().Add(-time.Minute)
|
||||
settlement := now.Add(20 * time.Second)
|
||||
input := service.FillInput{AccountID: account.ID, IdempotencyKey: uuid.NewString(), Side: "BUY", Symbol: "600000", Exchange: "SSE", Name: "测试证券", Quantity: "200", Price: "10.00", Fee: "5.01", OccurredAt: now, SellableAt: &settlement}
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan model.TradeFill, 2)
|
||||
failures := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
f, err := svc.Record(ctx, user, input, uuid.NewString())
|
||||
if err != nil {
|
||||
failures <- err
|
||||
} else {
|
||||
results <- f
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(failures)
|
||||
for err := range failures {
|
||||
t.Fatal(err)
|
||||
}
|
||||
one, two := <-results, <-results
|
||||
if one.ID != two.ID {
|
||||
t.Fatal("idempotent fill duplicated")
|
||||
}
|
||||
input.Price = "11.00"
|
||||
if _, err := svc.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("same idempotency key accepted different input")
|
||||
}
|
||||
input.Price = "10.00"
|
||||
portfolio, err := svc.Portfolio(ctx, user, account.ID)
|
||||
if err != nil || portfolio.CashBalance != "7994.99" || len(portfolio.Positions) != 1 || portfolio.Positions[0].CostBasis != "2005.01" || portfolio.Positions[0].MarketValue != nil {
|
||||
t.Fatalf("buy ledger %+v %v", portfolio, err)
|
||||
}
|
||||
stranger := user
|
||||
stranger.ID = uuid.NewString()
|
||||
if _, err := svc.Portfolio(ctx, stranger, account.ID); !errors.Is(err, apperror.ErrNotFound) {
|
||||
t.Fatal("portfolio owner isolation")
|
||||
}
|
||||
sell := input
|
||||
sell.Side = "SELL"
|
||||
sell.SellableAt = nil
|
||||
sell.IdempotencyKey = uuid.NewString()
|
||||
sell.Quantity = "100"
|
||||
sell.Price = "12.00"
|
||||
sell.Fee = "3.00"
|
||||
sell.OccurredAt = now.Add(10 * time.Second)
|
||||
if _, err := svc.Record(ctx, user, sell, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("unsettled shares sold")
|
||||
}
|
||||
sell.OccurredAt = now.Add(30 * time.Second)
|
||||
sale, err := svc.Record(ctx, user, sell, uuid.NewString())
|
||||
if err != nil || sale.RealizedPnL != "194.50" {
|
||||
t.Fatalf("FIFO sale %+v %v", sale, err)
|
||||
}
|
||||
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||
if err != nil || portfolio.CashBalance != "9191.99" || portfolio.RealizedPnL != "194.50" || portfolio.Review.TradeCount != 2 || portfolio.Review.TotalFees != "8.01" || portfolio.Review.BuyOutflow != "2005.01" || portfolio.Review.SellInflow != "1197.00" || portfolio.Review.ProfitableSellCount != 1 || portfolio.Positions[0].CostBasis != "1002.51" || portfolio.Positions[0].Quantity != "100" {
|
||||
t.Fatal("FIFO rounding or cash mismatch")
|
||||
}
|
||||
if _, err := svc.Reverse(ctx, user, one.ID, "非最新成交", uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("dependent buy reversed")
|
||||
}
|
||||
// Fail the cash reversal write and assert no partial restoration survives.
|
||||
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_cash_entry ADD CONSTRAINT veyra_test_reverse_failure CHECK(entry_type!='REVERSAL') NOT VALID`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Reverse(ctx, user, sale.ID, "回滚检查", uuid.NewString()); err == nil {
|
||||
t.Fatal("injected cash error ignored")
|
||||
}
|
||||
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||
if err != nil || portfolio.Positions[0].Quantity != "100" || portfolio.CashBalance != "9191.99" {
|
||||
t.Fatal("failed reversal changed positions")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_cash_entry DROP CONSTRAINT veyra_test_reverse_failure`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reversed, err := svc.Reverse(ctx, user, sale.ID, "纠正测试成交", uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate, err := svc.Reverse(ctx, user, sale.ID, "纠正测试成交", uuid.NewString())
|
||||
if err != nil || duplicate.ID != reversed.ID {
|
||||
t.Fatal("duplicate reversal applied twice")
|
||||
}
|
||||
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||
if err != nil || portfolio.CashBalance != "7994.99" || portfolio.RealizedPnL != "0.00" || portfolio.Positions[0].CostBasis != "2005.01" {
|
||||
t.Fatal("sell reversal restoration mismatch")
|
||||
}
|
||||
if _, err := svc.Reverse(ctx, user, one.ID, "纠正买入", uuid.NewString()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||
if err != nil || portfolio.CashBalance != "10000.00" || len(portfolio.Positions) != 0 {
|
||||
t.Fatal("buy reversal did not restore initial ledger")
|
||||
}
|
||||
fills, err := svc.List(ctx, user, account.ID)
|
||||
if err != nil || len(fills) != 2 || !fills[0].Reversed || !fills[1].Reversed {
|
||||
t.Fatal("original fill facts lost")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE veyra_trade_fill SET quantity=1 WHERE id=$1`, one.ID); err == nil {
|
||||
t.Fatal("posted fill mutation allowed")
|
||||
}
|
||||
input.IdempotencyKey = uuid.NewString()
|
||||
input.Quantity = "1000000"
|
||||
input.Price = "100.00"
|
||||
if _, err := svc.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("insufficient cash accepted")
|
||||
}
|
||||
var entries int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_cash_entry WHERE account_id=$1`, account.ID).Scan(&entries); err != nil || entries != 5 {
|
||||
t.Fatal("failed request left cash or duplicate cash entries")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testTradePlans(t *testing.T, ctx context.Context, pool *pgxpool.Pool, user model.User, candidate model.CandidateRevision) {
|
||||
t.Helper()
|
||||
svc := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true)
|
||||
accounts, err := service.NewAccountService(repository.NewAccountRepository(pool)).List(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var account string
|
||||
for _, a := range accounts {
|
||||
if a.Mode == "simulation" {
|
||||
account = a.ID
|
||||
}
|
||||
}
|
||||
input := service.PlanPreviewInput{AccountID: account, CandidateRevisionID: candidate.ID, ExpectedCandidateVersion: candidate.Version, Quantity: "9000", FeeBudget: "0"}
|
||||
first, err := svc.Preview(ctx, user, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := svc.Preview(ctx, user, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan model.TradePlan, 2)
|
||||
failures := make(chan error, 2)
|
||||
for _, p := range []model.PlanPreview{first, second} {
|
||||
wg.Add(1)
|
||||
go func(p model.PlanPreview) {
|
||||
defer wg.Done()
|
||||
plan, err := svc.Confirm(ctx, user, p.ID, uuid.NewString())
|
||||
if err != nil {
|
||||
failures <- err
|
||||
} else {
|
||||
results <- plan
|
||||
}
|
||||
}(p)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(failures)
|
||||
if len(results) != 1 || len(failures) != 1 {
|
||||
t.Fatal("concurrent plans overspent available cash")
|
||||
}
|
||||
for err := range failures {
|
||||
if !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
plan := <-results
|
||||
same, err := svc.Confirm(ctx, user, plan.PreviewID, uuid.NewString())
|
||||
if err != nil || same.ID != plan.ID {
|
||||
t.Fatal("confirmation retry created duplicate plan")
|
||||
}
|
||||
budget, err := svc.Budget(ctx, user, account)
|
||||
if err != nil || budget.ReservedAmount != "99000.00" || budget.AvailableAmount != "1000.01" {
|
||||
t.Fatalf("reservation budget: %+v %v", budget, err)
|
||||
}
|
||||
stranger := user
|
||||
stranger.ID = uuid.NewString()
|
||||
if _, err := svc.Confirm(ctx, stranger, first.ID, uuid.NewString()); !errors.Is(err, apperror.ErrNotFound) {
|
||||
t.Fatal("cross-user preview confirmation")
|
||||
}
|
||||
if _, err := svc.Cancel(ctx, user, plan.ID, plan.Version+1, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("stale plan cancellation")
|
||||
}
|
||||
if _, err := svc.Cancel(ctx, user, plan.ID, plan.Version, uuid.NewString()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
budget, err = svc.Budget(ctx, user, account)
|
||||
if err != nil || budget.ReservedAmount != "0.00" || budget.CashBalance != "100000.01" {
|
||||
t.Fatal("plan modified cash ledger or failed to release")
|
||||
}
|
||||
input.Quantity = "100"
|
||||
input.FeeBudget = "5.00"
|
||||
expired, err := svc.Preview(ctx, user, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE veyra_plan_preview SET expires_at=$2 WHERE id=$1`, expired.ID, time.Now().Add(-time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Confirm(ctx, user, expired.ID, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("expired preview confirmed")
|
||||
}
|
||||
input.ExpectedCandidateVersion++
|
||||
if _, err := svc.Preview(ctx, user, input); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("stale candidate preview accepted")
|
||||
}
|
||||
input.ExpectedCandidateVersion--
|
||||
active, err := svc.Preview(ctx, user, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Confirm(ctx, user, active.ID, uuid.NewString()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testNotifications(t, ctx, pool, user, candidate, account)
|
||||
testLinkedFills(t, ctx, pool, user, candidate, account)
|
||||
}
|
||||
|
||||
func testLinkedFills(t *testing.T, ctx context.Context, pool *pgxpool.Pool, user model.User, candidate model.CandidateRevision, account string) {
|
||||
t.Helper()
|
||||
plans := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true)
|
||||
fills := service.NewTradeFillService(repository.NewTradeFillRepository(pool), true)
|
||||
preview, err := plans.Preview(ctx, user, service.PlanPreviewInput{AccountID: account, CandidateRevisionID: candidate.ID, ExpectedCandidateVersion: candidate.Version, Quantity: "200", FeeBudget: "5"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := plans.Confirm(ctx, user, preview.ID, uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
occurred := time.Now().UTC().Add(-time.Second)
|
||||
settlement := occurred.Add(time.Hour)
|
||||
input := service.FillInput{AccountID: account, PlanID: &plan.ID, IdempotencyKey: uuid.NewString(), Side: "BUY", Symbol: candidate.Snapshot.Symbol, Exchange: candidate.Snapshot.Exchange, Name: candidate.Snapshot.Name, Quantity: "100", Price: "10", Fee: "2", OccurredAt: occurred, SellableAt: &settlement}
|
||||
first, err := fills.Record(ctx, user, input, uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := plans.List(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range list {
|
||||
if p.ID == plan.ID && (p.Status != "PART_FILLED" || p.FilledQuantity != 100 || p.ReservedAmount != "1105.00") {
|
||||
t.Fatal("partial fill reservation mismatch")
|
||||
}
|
||||
}
|
||||
input.IdempotencyKey = uuid.NewString()
|
||||
input.Price = "12"
|
||||
input.Fee = "3"
|
||||
input.OccurredAt = time.Now().UTC()
|
||||
if _, err := fills.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||
t.Fatal("unexplained price deviation accepted")
|
||||
}
|
||||
input.Note = "实际模拟成交偏离参考区间"
|
||||
last, err := fills.Record(ctx, user, input, uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err = plans.List(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range list {
|
||||
if p.ID == plan.ID && (p.Status != "COMPLETED" || p.FilledQuantity != 200 || p.ReservedMinor != 0) {
|
||||
t.Fatal("completed plan kept reservation")
|
||||
}
|
||||
}
|
||||
if _, err := fills.Reverse(ctx, user, last.ID, "纠正偏离录入", uuid.NewString()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fills.Reverse(ctx, user, first.ID, "纠正第一笔录入", uuid.NewString()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err = plans.List(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range list {
|
||||
if p.ID == plan.ID && (p.Status != "INVALIDATED" || p.FilledQuantity != 0 || p.ReservedMinor != 0) {
|
||||
t.Fatal("reversal silently reopened plan")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Package logging supplies readable console output and rotating structured files.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Directory, Level string
|
||||
MaxSizeMB, MaxBackups, MaxAgeDays int
|
||||
}
|
||||
|
||||
func New(o Options) (*zap.Logger, func(), error) {
|
||||
level, err := zapcore.ParseLevel(o.Level)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid LOG_LEVEL")
|
||||
}
|
||||
if o.Directory == "" || o.MaxSizeMB < 1 || o.MaxBackups < 1 || o.MaxAgeDays < 1 {
|
||||
return nil, nil, fmt.Errorf("invalid logging configuration")
|
||||
}
|
||||
if err := os.MkdirAll(o.Directory, 0750); err != nil {
|
||||
return nil, nil, fmt.Errorf("create log directory: %w", err)
|
||||
}
|
||||
path := filepath.Join(o.Directory, "veyra.log")
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("open log file: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return nil, nil, fmt.Errorf("close initial log file: %w", err)
|
||||
}
|
||||
file := &lumberjack.Logger{Filename: path, MaxSize: o.MaxSizeMB, MaxBackups: o.MaxBackups, MaxAge: o.MaxAgeDays, Compress: true, LocalTime: true}
|
||||
encoder := zap.NewProductionEncoderConfig()
|
||||
encoder.TimeKey = "time"
|
||||
encoder.MessageKey = "message"
|
||||
encoder.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
console := encoder
|
||||
console.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||
core := zapcore.NewTee(
|
||||
zapcore.NewCore(zapcore.NewConsoleEncoder(console), zapcore.Lock(zapcore.AddSync(os.Stdout)), level),
|
||||
zapcore.NewCore(zapcore.NewJSONEncoder(encoder), zapcore.AddSync(file), level),
|
||||
)
|
||||
logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.DPanicLevel))
|
||||
return logger, func() { _ = logger.Sync(); _ = file.Close() }, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestFileOutputAndLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logger, closeLogs, err := New(Options{dir, "warn", 1, 2, 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger.Info("filtered")
|
||||
logger.Warn("request rejected", zap.String("request_id", "test-request"), zap.Int("status", 409))
|
||||
closeLogs()
|
||||
data, err := os.ReadFile(filepath.Join(dir, "veyra.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row struct {
|
||||
Level, Message, RequestID string
|
||||
Status int
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.Unmarshal(fields["level"], &row.Level)
|
||||
_ = json.Unmarshal(fields["message"], &row.Message)
|
||||
_ = json.Unmarshal(fields["request_id"], &row.RequestID)
|
||||
_ = json.Unmarshal(fields["status"], &row.Status)
|
||||
if row.Level != "warn" || row.Message != "request rejected" || row.RequestID != "test-request" || row.Status != 409 || strings.Contains(string(data), "filtered") {
|
||||
t.Fatalf("invalid structured log: %s", data)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dir, "veyra.log"))
|
||||
if err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("log file must be private")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogRotation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stdout := os.Stdout
|
||||
discard, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer discard.Close()
|
||||
os.Stdout = discard
|
||||
logger, closeLogs, err := New(Options{dir, "info", 1, 2, 1})
|
||||
os.Stdout = stdout
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer closeLogs()
|
||||
for i := 0; i < 3; i++ {
|
||||
logger.Info("rotation", zap.String("payload", strings.Repeat("x", 400000)))
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), "veyra-") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("exceeding size must rotate the log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotOpenLogs(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "file")
|
||||
if err := os.WriteFile(path, nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := New(Options{path, "info", 1, 1, 1}); err == nil {
|
||||
t.Fatal("unwritable log directory must fail startup")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package market adapts external market responses without floating point prices.
|
||||
package market
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct{ http *http.Client }
|
||||
|
||||
func New() *Client {
|
||||
return &Client{http: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
}
|
||||
func (c *Client) get(ctx context.Context, base, path string, params url.Values, target any, timeout ...int) error {
|
||||
seconds := 4
|
||||
if len(timeout) > 0 && timeout[0] >= 1 && timeout[0] <= 30 {
|
||||
seconds = timeout[0]
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second)
|
||||
defer cancel()
|
||||
u, e := url.Parse(base)
|
||||
if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return errors.New("invalid market endpoint")
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/") + path
|
||||
u.RawQuery = params.Encode()
|
||||
req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if e != nil {
|
||||
return errors.New("invalid market request")
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
referer := "https://finance.sina.com.cn/"
|
||||
if strings.HasSuffix(u.Hostname(), "eastmoney.com") {
|
||||
referer = "https://quote.eastmoney.com/"
|
||||
}
|
||||
req.Header.Set("Referer", referer)
|
||||
response, e := c.http.Do(req)
|
||||
if e != nil {
|
||||
return errors.New("market provider unavailable")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != 200 {
|
||||
return errors.New("market provider rejected request")
|
||||
}
|
||||
data, e := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1))
|
||||
if e != nil || len(data) > 4<<20 {
|
||||
return errors.New("invalid market response size")
|
||||
}
|
||||
if json.Unmarshal(data, target) != nil {
|
||||
return errors.New("invalid market response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Client) Snapshot(ctx context.Context, config model.ProviderConfig) ([]model.Instrument, error) {
|
||||
if config.MarketAdapter == "sina_public" {
|
||||
return c.sinaSnapshot(ctx, config)
|
||||
}
|
||||
list := make([]model.Instrument, 0)
|
||||
seen := map[string]bool{}
|
||||
for page := 1; page <= 120; page++ {
|
||||
var response struct {
|
||||
RC int `json:"rc"`
|
||||
Data *struct {
|
||||
Total int `json:"total"`
|
||||
Diff map[string]struct {
|
||||
Symbol string `json:"f12"`
|
||||
Market int `json:"f13"`
|
||||
Name string `json:"f14"`
|
||||
Price json.RawMessage `json:"f2"`
|
||||
Observed int64 `json:"f124"`
|
||||
Decimals int `json:"f152"`
|
||||
} `json:"diff"`
|
||||
} `json:"data"`
|
||||
}
|
||||
p := url.Values{"pn": {strconv.Itoa(page)}, "pz": {"100"}, "fs": {"m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23"}, "fields": {"f12,f13,f14,f2,f124,f152"}, "fid": {"f12"}, "po": {"0"}}
|
||||
if e := c.get(ctx, config.BaseURL, "/api/qt/clist/get", p, &response, config.TimeoutSeconds); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if response.RC != 0 || response.Data == nil || response.Data.Total < 1 || response.Data.Total > 12000 || len(response.Data.Diff) == 0 {
|
||||
return nil, errors.New("invalid market universe")
|
||||
}
|
||||
for _, row := range response.Data.Diff {
|
||||
if len(row.Symbol) != 6 || row.Name == "" || (row.Market != 0 && row.Market != 1) || row.Decimals != 2 {
|
||||
return nil, errors.New("unsupported market precision")
|
||||
}
|
||||
price, e := strconv.ParseInt(string(row.Price), 10, 64)
|
||||
if e != nil || price < 0 || price > 100000000 {
|
||||
continue
|
||||
}
|
||||
when := time.Unix(row.Observed, 0).UTC()
|
||||
if row.Observed <= 0 || when.After(time.Now().Add(time.Minute)) {
|
||||
continue
|
||||
}
|
||||
exchange := "SZSE"
|
||||
if row.Market == 1 {
|
||||
exchange = "SSE"
|
||||
}
|
||||
key := exchange + row.Symbol
|
||||
if seen[key] {
|
||||
return nil, errors.New("duplicate market page")
|
||||
}
|
||||
seen[key] = true
|
||||
list = append(list, model.Instrument{Symbol: row.Symbol, Exchange: exchange, Name: row.Name, PriceCents: price, Price: model.FormatMoney(price), ObservedAt: when, SourceID: config.ID, SourceVersion: config.Version})
|
||||
}
|
||||
if page*100 >= response.Data.Total {
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Symbol < list[j].Symbol })
|
||||
return list, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return nil, errors.New("market pagination limit exceeded")
|
||||
}
|
||||
func (c *Client) History(ctx context.Context, config model.ProviderConfig, symbol, exchange, start, end string) (model.MarketHistory, error) {
|
||||
if config.MarketAdapter == "sina_public" {
|
||||
return c.sinaHistory(ctx, config, symbol, exchange, start, end)
|
||||
}
|
||||
var response struct {
|
||||
RC int `json:"rc"`
|
||||
Data *struct {
|
||||
Code, Name string
|
||||
Klines []string `json:"klines"`
|
||||
} `json:"data"`
|
||||
}
|
||||
secid := "0." + symbol
|
||||
if exchange == "SSE" {
|
||||
secid = "1." + symbol
|
||||
}
|
||||
params := url.Values{"secid": {secid}, "klt": {"101"}, "fqt": {"0"}, "beg": {strings.ReplaceAll(start, "-", "")}, "end": {strings.ReplaceAll(end, "-", "")}, "fields1": {"f1,f2,f3,f4,f5,f6"}, "fields2": {"f51,f52,f53,f54,f55,f56,f57"}}
|
||||
result := model.MarketHistory{Symbol: symbol, Exchange: exchange, Source: "eastmoney_public", SourceVersion: config.Version, Adjustment: "unadjusted", ReceivedAt: time.Now().UTC(), Bars: make([]model.HistoricalBar, 0)}
|
||||
if e := c.get(ctx, config.HistoryBaseURL, "/api/qt/stock/kline/get", params, &response, config.TimeoutSeconds); e != nil {
|
||||
return result, e
|
||||
}
|
||||
if response.RC != 0 || response.Data == nil || response.Data.Code != symbol || len(response.Data.Klines) > 1000 {
|
||||
return result, errors.New("invalid market history")
|
||||
}
|
||||
result.Name = response.Data.Name
|
||||
previous := ""
|
||||
for _, line := range response.Data.Klines {
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) < 5 {
|
||||
return result, errors.New("invalid historical bar")
|
||||
}
|
||||
date, e := time.Parse("2006-01-02", parts[0])
|
||||
if e != nil || parts[0] <= previous || parts[0] < start || parts[0] > end || date.After(time.Now()) {
|
||||
return result, errors.New("invalid historical date")
|
||||
}
|
||||
previous = parts[0]
|
||||
o, e1 := model.ParseMoney(parts[1])
|
||||
close, e2 := model.ParseMoney(parts[2])
|
||||
high, e3 := model.ParseMoney(parts[3])
|
||||
low, e4 := model.ParseMoney(parts[4])
|
||||
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || low <= 0 || high > 100000000 || low > o || low > close || high < o || high < close {
|
||||
return result, fmt.Errorf("invalid historical OHLC")
|
||||
}
|
||||
result.Bars = append(result.Bars, model.HistoricalBar{Date: parts[0], Open: model.FormatMoney(o), High: model.FormatMoney(high), Low: model.FormatMoney(low), Close: model.FormatMoney(close)})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package market
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarketPrecisionAndTimestamp(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"rc":0,"data":{"total":1,"diff":{"0":{"f12":"600000","f13":1,"f14":"fixture","f2":1001,"f124":1700000000,"f152":2}}}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &Client{http: server.Client()}
|
||||
rows, e := client.Snapshot(context.Background(), model.ProviderConfig{BaseURL: server.URL, ID: "source", Version: 3})
|
||||
if e != nil || len(rows) != 1 {
|
||||
t.Fatalf("snapshot: %v", e)
|
||||
}
|
||||
if rows[0].Price != "10.01" || rows[0].ObservedAt.Unix() != 1700000000 || rows[0].SourceVersion != 3 {
|
||||
t.Fatal("lost integer precision or provider timestamp")
|
||||
}
|
||||
}
|
||||
func TestHistoryOHLCAndDates(t *testing.T) {
|
||||
response := `{"rc":0,"data":{"code":"600000","name":"fixture","klines":["2025-01-02,10.01,10.11,10.21,9.99,100,1"]}}`
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("fqt") != "0" {
|
||||
t.Error("adjustment changed")
|
||||
}
|
||||
fmt.Fprint(w, response)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &Client{http: server.Client()}
|
||||
input := model.ProviderConfig{HistoryBaseURL: server.URL, Version: 4}
|
||||
data, e := client.History(context.Background(), input, "600000", "SSE", "2025-01-01", "2025-01-03")
|
||||
if e != nil || len(data.Bars) != 1 || data.Bars[0].Close != "10.11" || data.Adjustment != "unadjusted" {
|
||||
t.Fatalf("history: %v", e)
|
||||
}
|
||||
response = `{"rc":0,"data":{"code":"600000","name":"fixture","klines":["2025-01-02,10.01,10.11,10.00,9.99,100,1"]}}`
|
||||
if _, e := client.History(context.Background(), input, "600000", "SSE", "2025-01-01", "2025-01-03"); e == nil {
|
||||
t.Fatal("invalid high accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSinaQuotesKeepVendorDate(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/quotes_service/api/json_v2.php/Market_Center.getHQNodeData":
|
||||
fmt.Fprint(w, `[{"symbol":"sh600000","code":"600000","name":"fixture"}]`)
|
||||
case "/list=sh600000":
|
||||
fmt.Fprint(w, `var hq_str_sh600000="fixture,10.000,10.000,10.010,10.100,9.900,10.000,10.010,100,1000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2025-01-02,15:00:00,00";`)
|
||||
default:
|
||||
t.Errorf("unexpected route %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &Client{http: server.Client()}
|
||||
rows, e := client.Snapshot(context.Background(), model.ProviderConfig{MarketAdapter: "sina_public", BaseURL: server.URL, QuoteBaseURL: server.URL})
|
||||
if e != nil || len(rows) != 1 || rows[0].Price != "10.01" || rows[0].ObservedAt.Format("2006-01-02T15:04:05Z") != "2025-01-02T07:00:00Z" {
|
||||
t.Fatalf("quote validation: %+v, %v", rows, e)
|
||||
}
|
||||
}
|
||||
func TestPublicPricesRejectRounding(t *testing.T) {
|
||||
for _, v := range []string{"10.001", "1e2", "NaN"} {
|
||||
if _, e := exactPublicPrice(v); e == nil {
|
||||
t.Fatalf("rounded or invalid price %s", v)
|
||||
}
|
||||
}
|
||||
if v, e := exactPublicPrice("10.010"); e != nil || v != 1001 {
|
||||
t.Fatal("exact extra zero rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package market
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
func exactPublicPrice(s string) (int64, error) {
|
||||
if strings.Contains(s, ".") {
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimSuffix(s, ".")
|
||||
}
|
||||
return model.ParseMoney(s)
|
||||
}
|
||||
func (c *Client) sinaSnapshot(ctx context.Context, config model.ProviderConfig) ([]model.Instrument, error) {
|
||||
all := make([]model.Instrument, 0)
|
||||
seen := map[string]bool{}
|
||||
zone, _ := time.LoadLocation("Asia/Shanghai")
|
||||
for page := 1; page <= 150; page++ {
|
||||
var rows []struct{ Symbol, Code, Name string }
|
||||
p := url.Values{"page": {strconv.Itoa(page)}, "num": {"80"}, "sort": {"symbol"}, "asc": {"1"}, "node": {"hs_a"}, "symbol": {""}}
|
||||
p.Set("symbol", "")
|
||||
if e := c.get(ctx, config.BaseURL, "/quotes_service/api/json_v2.php/Market_Center.getHQNodeData", p, &rows, config.TimeoutSeconds); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
if len(all) == 0 {
|
||||
return nil, errors.New("empty market universe")
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
symbols := make([]string, 0)
|
||||
names := map[string]string{}
|
||||
for _, row := range rows {
|
||||
if !(strings.HasPrefix(row.Symbol, "sh") || strings.HasPrefix(row.Symbol, "sz")) || len(row.Code) != 6 {
|
||||
continue
|
||||
}
|
||||
if seen[row.Symbol] {
|
||||
return nil, errors.New("duplicate market page")
|
||||
}
|
||||
seen[row.Symbol] = true
|
||||
symbols = append(symbols, row.Symbol)
|
||||
names[row.Symbol] = row.Name
|
||||
}
|
||||
if len(symbols) > 0 {
|
||||
u, e := url.Parse(config.QuoteBaseURL)
|
||||
if e != nil {
|
||||
return nil, errors.New("invalid quote endpoint")
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/") + "/list=" + strings.Join(symbols, ",")
|
||||
req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if e != nil {
|
||||
return nil, errors.New("invalid quote request")
|
||||
}
|
||||
req.Header.Set("Referer", "https://finance.sina.com.cn/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
seconds := config.TimeoutSeconds
|
||||
if seconds < 1 || seconds > 30 {
|
||||
seconds = 4
|
||||
}
|
||||
quoteCtx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second)
|
||||
req = req.WithContext(quoteCtx)
|
||||
resp, e := c.http.Do(req)
|
||||
if e != nil {
|
||||
cancel()
|
||||
return nil, errors.New("quote provider unavailable")
|
||||
}
|
||||
data, readError := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
if readError != nil || resp.StatusCode != 200 {
|
||||
return nil, errors.New("quote provider rejected request")
|
||||
}
|
||||
decoded, e := simplifiedchinese.GBK.NewDecoder().Bytes(data)
|
||||
if e != nil {
|
||||
return nil, errors.New("invalid quote text")
|
||||
}
|
||||
for _, line := range strings.Split(string(decoded), ";") {
|
||||
left, right, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
symbol := strings.TrimPrefix(left, "var hq_str_")
|
||||
name, exists := names[symbol]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(strings.Trim(right, "\"\r\n "), ",")
|
||||
if len(parts) < 32 {
|
||||
continue
|
||||
}
|
||||
price, e := exactPublicPrice(parts[3])
|
||||
when, dateError := time.ParseInLocation("2006-01-02 15:04:05", parts[30]+" "+parts[31], zone)
|
||||
if e != nil || dateError != nil || price <= 0 || price > 100000000 || when.After(time.Now().Add(time.Minute)) {
|
||||
continue
|
||||
}
|
||||
exchange := "SZSE"
|
||||
if strings.HasPrefix(symbol, "sh") {
|
||||
exchange = "SSE"
|
||||
}
|
||||
all = append(all, model.Instrument{Symbol: symbol[2:], Exchange: exchange, Name: name, Price: model.FormatMoney(price), PriceCents: price, ObservedAt: when.UTC(), SourceID: config.ID, SourceVersion: config.Version})
|
||||
}
|
||||
}
|
||||
if len(rows) < 80 {
|
||||
if len(all) == 0 {
|
||||
return nil, errors.New("no valid market quotes")
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return nil, errors.New("market universe exceeds limit")
|
||||
}
|
||||
func (c *Client) sinaHistory(ctx context.Context, config model.ProviderConfig, symbol, exchange, start, end string) (model.MarketHistory, error) {
|
||||
ticker := "sz" + symbol
|
||||
if exchange == "SSE" {
|
||||
ticker = "sh" + symbol
|
||||
}
|
||||
var envelope struct {
|
||||
Result struct {
|
||||
Status struct{ Code int }
|
||||
Data []struct{ Day, Open, High, Low, Close string }
|
||||
}
|
||||
}
|
||||
p := url.Values{"symbol": {ticker}, "scale": {"240"}, "ma": {"no"}, "datalen": {"1000"}}
|
||||
result := model.MarketHistory{Symbol: symbol, Exchange: exchange, Source: "sina_public", SourceVersion: config.Version, Adjustment: "unadjusted", ReceivedAt: time.Now().UTC(), Bars: make([]model.HistoricalBar, 0)}
|
||||
if e := c.get(ctx, config.HistoryBaseURL, "/cn/api/openapi.php/CN_MarketDataService.getKLineData", p, &envelope, config.TimeoutSeconds); e != nil {
|
||||
return result, e
|
||||
}
|
||||
if envelope.Result.Status.Code != 0 || len(envelope.Result.Data) == 0 {
|
||||
return result, errors.New("historical provider unavailable")
|
||||
}
|
||||
previous := ""
|
||||
for _, b := range envelope.Result.Data {
|
||||
date := strings.Split(b.Day, " ")[0]
|
||||
if date < start || date > end {
|
||||
continue
|
||||
}
|
||||
_, e := time.Parse("2006-01-02", date)
|
||||
o, e1 := exactPublicPrice(b.Open)
|
||||
h, e2 := exactPublicPrice(b.High)
|
||||
l, e3 := exactPublicPrice(b.Low)
|
||||
v, e4 := exactPublicPrice(b.Close)
|
||||
if e != nil || date <= previous || e1 != nil || e2 != nil || e3 != nil || e4 != nil || l <= 0 || l > o || l > v || h < o || h < v || h > 100000000 {
|
||||
return result, errors.New("invalid provider OHLC")
|
||||
}
|
||||
previous = date
|
||||
result.Bars = append(result.Bars, model.HistoricalBar{Date: date, Open: model.FormatMoney(o), High: model.FormatMoney(h), Low: model.FormatMoney(l), Close: model.FormatMoney(v)})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HistoricalBar struct {
|
||||
Date string `json:"date"`
|
||||
Open string `json:"open"`
|
||||
High string `json:"high"`
|
||||
Low string `json:"low"`
|
||||
Close string `json:"close"`
|
||||
}
|
||||
type BacktestInput struct {
|
||||
DataOrigin string `json:"data_origin"`
|
||||
SourceVersion int64 `json:"source_version"`
|
||||
DataReceivedAt *time.Time `json:"data_received_at"`
|
||||
StrategyVersionID string `json:"strategy_version_id"`
|
||||
DatasetName string `json:"dataset_name"`
|
||||
Symbol string `json:"symbol"`
|
||||
InitialCash string `json:"initial_cash"`
|
||||
Bars []HistoricalBar `json:"bars"`
|
||||
}
|
||||
type ResearchInput struct {
|
||||
BacktestInput
|
||||
Parameters StrategyParameters `json:"parameters"`
|
||||
}
|
||||
type BacktestRun struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
StrategyVersionID string `db:"strategy_version_id" json:"strategy_version_id"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Input json.RawMessage `db:"input" json:"input,omitempty"`
|
||||
InputHash string `db:"input_hash" json:"input_hash"`
|
||||
Result json.RawMessage `db:"result" json:"result"`
|
||||
Failure string `db:"failure" json:"failure"`
|
||||
LeaseID *string `db:"lease_id" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
FinishedAt *time.Time `db:"finished_at" json:"finished_at"`
|
||||
}
|
||||
|
||||
func (BacktestRun) TableName() string { return "veyra_backtest_run" }
|
||||
@@ -0,0 +1,183 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
)
|
||||
|
||||
type Candidate struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
LatestRevision int `db:"latest_revision" json:"latest_revision"`
|
||||
PublishedRevisionID *string `db:"published_revision_id" json:"published_revision_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (Candidate) TableName() string { return "veyra_candidate" }
|
||||
|
||||
type CandidateEvidence struct {
|
||||
SourceURL string `json:"source_url"`
|
||||
Locator string `json:"locator"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
type CandidateReferencePlan struct {
|
||||
EntryLow string `json:"entry_low"`
|
||||
EntryHigh string `json:"entry_high"`
|
||||
StopLoss string `json:"stop_loss"`
|
||||
TargetPrice string `json:"target_price"`
|
||||
}
|
||||
type CandidateContent struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Exchange string `json:"exchange"`
|
||||
Name string `json:"name"`
|
||||
StrategyCode string `json:"strategy_code"`
|
||||
Title string `json:"title"`
|
||||
Thesis string `json:"thesis"`
|
||||
Evidence []CandidateEvidence `json:"evidence"`
|
||||
CounterEvidence string `json:"counter_evidence"`
|
||||
Invalidation string `json:"invalidation"`
|
||||
ReferencePlan CandidateReferencePlan `json:"reference_plan"`
|
||||
}
|
||||
|
||||
type CandidateRevision struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
CandidateID string `db:"candidate_id" json:"candidate_id"`
|
||||
Revision int `db:"revision" json:"revision"`
|
||||
Version int64 `db:"version" json:"version"`
|
||||
Status string `db:"status" json:"status"`
|
||||
DataMode string `db:"data_mode" json:"data_mode"`
|
||||
Snapshot CandidateContent `db:"snapshot" json:"content"`
|
||||
AuthorID string `db:"author_id" json:"author_id"`
|
||||
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
|
||||
ValidUntil time.Time `db:"valid_until" json:"valid_until"`
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
IsLatest bool `json:"is_latest"`
|
||||
WithdrawalReason string `json:"withdrawal_reason"`
|
||||
IsActivePublication bool `json:"is_active_publication"`
|
||||
}
|
||||
|
||||
func (CandidateRevision) TableName() string { return "veyra_candidate_revision" }
|
||||
|
||||
type CandidateReview struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
RevisionID string `db:"revision_id" json:"revision_id"`
|
||||
ReviewerID string `db:"reviewer_id" json:"reviewer_id"`
|
||||
Decision string `db:"decision" json:"decision"`
|
||||
InputVersion int64 `db:"input_version" json:"input_version"`
|
||||
Reason string `db:"reason" json:"reason"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (CandidateReview) TableName() string { return "veyra_candidate_review" }
|
||||
|
||||
type CandidateDraftInput struct {
|
||||
Content CandidateContent `json:"content"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
}
|
||||
type CandidateRevisionInput struct {
|
||||
CandidateDraftInput
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
}
|
||||
type CandidateActionInput struct {
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
type CandidateDetail struct {
|
||||
CandidateRevision
|
||||
Reviews []CandidateReview `json:"reviews"`
|
||||
}
|
||||
|
||||
var symbolPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||
|
||||
func textValid(s string, max int) bool {
|
||||
return strings.TrimSpace(s) != "" && utf8.RuneCountInString(s) <= max
|
||||
}
|
||||
|
||||
func ValidateDraft(input CandidateDraftInput, now time.Time) error {
|
||||
c := input.Content
|
||||
if !symbolPattern.MatchString(c.Symbol) || c.Exchange != "SSE" && c.Exchange != "SZSE" || !textValid(c.Name, 80) || !textValid(c.Title, 120) || !textValid(c.Thesis, 2000) || !textValid(c.CounterEvidence, 1000) || !textValid(c.Invalidation, 1000) {
|
||||
return apperror.ErrInvalidInput
|
||||
}
|
||||
switch c.StrategyCode {
|
||||
case "trend_following", "pullback", "event_driven":
|
||||
default:
|
||||
return apperror.ErrInvalidInput
|
||||
}
|
||||
if len(c.Evidence) < 1 || len(c.Evidence) > 8 {
|
||||
return apperror.ErrInvalidInput
|
||||
}
|
||||
for _, e := range c.Evidence {
|
||||
u, err := url.Parse(e.SourceURL)
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || len(e.SourceURL) > 2048 || !textValid(e.Locator, 200) || !textValid(e.Summary, 1000) {
|
||||
return apperror.ErrInvalidInput
|
||||
}
|
||||
}
|
||||
low, e1 := ParseMoney(c.ReferencePlan.EntryLow)
|
||||
high, e2 := ParseMoney(c.ReferencePlan.EntryHigh)
|
||||
stop, e3 := ParseMoney(c.ReferencePlan.StopLoss)
|
||||
target, e4 := ParseMoney(c.ReferencePlan.TargetPrice)
|
||||
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || stop <= 0 || stop >= low || low > high || target <= high {
|
||||
return apperror.ErrInvalidInput
|
||||
}
|
||||
if input.ObservedAt.IsZero() || input.ObservedAt.After(now.Add(time.Minute)) || input.ObservedAt.Before(now.Add(-24*time.Hour)) || !input.ValidUntil.After(now) || input.ValidUntil.After(now.Add(7*24*time.Hour)) || !input.ValidUntil.After(input.ObservedAt) {
|
||||
return apperror.ErrInvalidInput
|
||||
}
|
||||
encoded, err := json.Marshal(c)
|
||||
if err != nil || len(encoded) > 12<<10 {
|
||||
return apperror.ErrInvalidInput
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NextStatus is the shared state rule; repositories recheck it under the aggregate lock.
|
||||
func NextStatus(r CandidateRevision, action string, now time.Time) (string, error) {
|
||||
if action == "WITHDRAW" {
|
||||
if r.Status != "PUBLISHED" || !r.IsActivePublication {
|
||||
return "", apperror.ErrConflict
|
||||
}
|
||||
return "WITHDRAWN", nil
|
||||
}
|
||||
if !r.IsLatest {
|
||||
return "", apperror.ErrConflict
|
||||
}
|
||||
switch action {
|
||||
case "SUBMIT":
|
||||
if r.Status != "DRAFT" {
|
||||
return "", apperror.ErrConflict
|
||||
}
|
||||
if err := ValidateDraft(CandidateDraftInput{Content: r.Snapshot, ObservedAt: r.ObservedAt, ValidUntil: r.ValidUntil}, now); err != nil {
|
||||
return "", apperror.ErrInvalidInput
|
||||
}
|
||||
return "PENDING_REVIEW", nil
|
||||
case "PUBLISH":
|
||||
if r.Status != "PENDING_REVIEW" {
|
||||
return "", apperror.ErrConflict
|
||||
}
|
||||
if err := ValidateDraft(CandidateDraftInput{Content: r.Snapshot, ObservedAt: r.ObservedAt, ValidUntil: r.ValidUntil}, now); err != nil {
|
||||
return "", apperror.ErrInvalidInput
|
||||
}
|
||||
return "PUBLISHED", nil
|
||||
case "RETURN":
|
||||
if r.Status != "PENDING_REVIEW" {
|
||||
return "", apperror.ErrConflict
|
||||
}
|
||||
return "RETURNED", nil
|
||||
default:
|
||||
return "", apperror.ErrInvalidInput
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateReason(reason string) error {
|
||||
if !textValid(reason, 1000) {
|
||||
return apperror.ErrInvalidInput
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Instrument struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Symbol string `db:"symbol" json:"symbol"`
|
||||
Exchange string `db:"exchange" json:"exchange"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Price string `json:"price"`
|
||||
PriceCents int64 `db:"price_cents" json:"-"`
|
||||
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
|
||||
ReceivedAt time.Time `db:"received_at" json:"received_at"`
|
||||
SourceID string `db:"source_id" json:"source_id"`
|
||||
SourceVersion int64 `db:"source_version" json:"source_version"`
|
||||
}
|
||||
|
||||
func (Instrument) TableName() string { return "veyra_instrument" }
|
||||
|
||||
type MarketSync struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
SourceID string `db:"source_id" json:"source_id"`
|
||||
SourceVersion int64 `db:"source_version" json:"source_version"`
|
||||
InstrumentCount int `db:"instrument_count" json:"instrument_count"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Message string `db:"message" json:"message"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
FinishedAt time.Time `db:"finished_at" json:"finished_at"`
|
||||
}
|
||||
|
||||
func (MarketSync) TableName() string { return "veyra_market_sync" }
|
||||
|
||||
type MarketHistory struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Exchange string `json:"exchange"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
SourceVersion int64 `json:"source_version"`
|
||||
Adjustment string `json:"adjustment"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Bars []HistoricalBar `json:"bars"`
|
||||
}
|
||||
@@ -33,4 +33,12 @@ func ParseMoney(value string) (int64, error) {
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func FormatMoney(minor int64) string { return fmt.Sprintf("%d.%02d", minor/100, minor%100) }
|
||||
func FormatMoney(minor int64) string {
|
||||
sign := ""
|
||||
absolute := uint64(minor)
|
||||
if minor < 0 {
|
||||
sign = "-"
|
||||
absolute = uint64(-(minor + 1)) + 1
|
||||
}
|
||||
return fmt.Sprintf("%s%d.%02d", sign, absolute/100, absolute%100)
|
||||
}
|
||||
|
||||
@@ -19,3 +19,14 @@ func TestExactMoney(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedLedgerMoney(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
minor int64
|
||||
want string
|
||||
}{{-1, "-0.01"}, {-100, "-1.00"}, {-1005, "-10.05"}, {0, "0.00"}, {-9223372036854775808, "-92233720368547758.08"}} {
|
||||
if got := FormatMoney(tc.minor); got != tc.want {
|
||||
t.Fatalf("got %s want %s", got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Notification struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
UserID string `db:"user_id" json:"-"`
|
||||
EventID string `db:"event_id" json:"event_id"`
|
||||
PlanID *string `db:"plan_id" json:"plan_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Body string `db:"body" json:"body"`
|
||||
ReadAt *time.Time `db:"read_at" json:"read_at"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (Notification) TableName() string { return "veyra_notification" }
|
||||
|
||||
type QuoteSnapshot struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Symbol string `db:"symbol" json:"symbol"`
|
||||
Exchange string `db:"exchange" json:"exchange"`
|
||||
DataMode string `db:"data_mode" json:"data_mode"`
|
||||
PriceMinor int64 `db:"price_minor" json:"-"`
|
||||
Price string `json:"price"`
|
||||
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
|
||||
Version int64 `db:"version" json:"version"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (QuoteSnapshot) TableName() string { return "veyra_quote_snapshot" }
|
||||
|
||||
type PlanTrigger struct {
|
||||
ID string `db:"id"`
|
||||
PlanID string `db:"plan_id"`
|
||||
QuoteSnapshotID string `db:"quote_snapshot_id"`
|
||||
QuoteVersion int64 `db:"quote_version"`
|
||||
TriggeredAt time.Time `db:"triggered_at"`
|
||||
}
|
||||
|
||||
func (PlanTrigger) TableName() string { return "veyra_plan_trigger" }
|
||||
|
||||
type NotificationTask struct {
|
||||
OutboxEvent
|
||||
RecipientCount int `json:"recipient_count"`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OutboxEvent struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
AggregateType string `db:"aggregate_type" json:"aggregate_type"`
|
||||
AggregateID string `db:"aggregate_id" json:"aggregate_id"`
|
||||
AggregateVersion int64 `db:"aggregate_version" json:"aggregate_version"`
|
||||
EventType string `db:"event_type" json:"event_type"`
|
||||
Payload json.RawMessage `db:"payload" json:"payload"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
ProcessedAt *time.Time `db:"processed_at" json:"processed_at"`
|
||||
}
|
||||
|
||||
func (OutboxEvent) TableName() string { return "veyra_outbox_event" }
|
||||
@@ -0,0 +1,59 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProviderConfig struct {
|
||||
QuoteBaseURL string `db:"quote_base_url" json:"quote_base_url"`
|
||||
MarketAdapter string `db:"market_adapter" json:"market_adapter"`
|
||||
HistoryBaseURL string `db:"history_base_url" json:"history_base_url"`
|
||||
ID string `db:"id" json:"id"`
|
||||
ConfigKey string `db:"config_key" json:"config_key"`
|
||||
Name string `db:"name" json:"name"`
|
||||
BaseURL string `db:"base_url" json:"base_url"`
|
||||
ModelName string `db:"model_name" json:"model_name"`
|
||||
WireAPI string `db:"wire_api" json:"wire_api"`
|
||||
ModelCatalogURL string `db:"model_catalog_url" json:"model_catalog_url"`
|
||||
DisableResponseStorage bool `db:"disable_response_storage" json:"disable_response_storage"`
|
||||
RequiresOpenAIAuth bool `db:"requires_openai_auth" json:"requires_openai_auth"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
TimeoutSeconds int `db:"timeout_seconds" json:"timeout_seconds"`
|
||||
EncryptedAPIKey []byte `db:"encrypted_api_key" json:"-"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
Version int64 `db:"version" json:"version"`
|
||||
LastTestStatus string `db:"last_test_status" json:"last_test_status"`
|
||||
LastTestedAt *time.Time `db:"last_tested_at" json:"last_tested_at"`
|
||||
LastTestMessage string `db:"last_test_message" json:"last_test_message"`
|
||||
UpdatedBy string `db:"updated_by" json:"updated_by"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ProviderConfig) TableName() string { return "veyra_provider_config" }
|
||||
|
||||
type ProviderConfigHistory struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
ConfigID string `db:"config_id" json:"config_id"`
|
||||
Version int64 `db:"version" json:"version"`
|
||||
Snapshot json.RawMessage `db:"snapshot" json:"snapshot"`
|
||||
EncryptedAPIKey []byte `db:"encrypted_api_key" json:"-"`
|
||||
ActorID string `db:"actor_id" json:"actor_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (ProviderConfigHistory) TableName() string { return "veyra_provider_config_history" }
|
||||
|
||||
type AuditEvent struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
ActorID string `db:"actor_id" json:"actor_id"`
|
||||
Action string `db:"action" json:"action"`
|
||||
ObjectType string `db:"object_type" json:"object_type"`
|
||||
ObjectID string `db:"object_id" json:"object_id"`
|
||||
RequestID string `db:"request_id" json:"request_id"`
|
||||
Details json.RawMessage `db:"details" json:"details"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (AuditEvent) TableName() string { return "veyra_audit_event" }
|
||||
@@ -0,0 +1,42 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type StrategyParameters struct {
|
||||
ShortWindow int `json:"short_window"`
|
||||
LongWindow int `json:"long_window"`
|
||||
FeeBPS int `json:"fee_bps"`
|
||||
SlippageBPS int `json:"slippage_bps"`
|
||||
LotSize int64 `json:"lot_size"`
|
||||
}
|
||||
type Strategy struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Status string `db:"status" json:"status"`
|
||||
LatestRevision int `db:"latest_revision" json:"latest_revision"`
|
||||
ActiveVersionID *string `db:"active_version_id" json:"active_version_id"`
|
||||
Version int64 `db:"version" json:"version"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Strategy) TableName() string { return "veyra_strategy" }
|
||||
|
||||
type StrategyVersion struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
StrategyID string `db:"strategy_id" json:"strategy_id"`
|
||||
Revision int `db:"revision" json:"revision"`
|
||||
Algorithm string `db:"algorithm" json:"algorithm"`
|
||||
Parameters StrategyParameters `db:"parameters" json:"parameters"`
|
||||
Description string `db:"description" json:"description"`
|
||||
AuthorID string `db:"author_id" json:"author_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (StrategyVersion) TableName() string { return "veyra_strategy_version" }
|
||||
|
||||
type StrategyDetail struct {
|
||||
Strategy
|
||||
Versions []StrategyVersion `json:"versions"`
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type TradeFill struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
AccountID string `db:"account_id" json:"account_id"`
|
||||
PlanID *string `db:"plan_id" json:"plan_id"`
|
||||
IdempotencyKey string `db:"idempotency_key" json:"idempotency_key"`
|
||||
InputHash string `db:"input_hash" json:"-"`
|
||||
PostedSequence int64 `db:"posted_sequence" json:"-"`
|
||||
Side string `db:"side" json:"side"`
|
||||
Symbol string `db:"symbol" json:"symbol"`
|
||||
Exchange string `db:"exchange" json:"exchange"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Quantity int64 `db:"quantity" json:"quantity,string"`
|
||||
PriceMinor int64 `db:"price_minor" json:"-"`
|
||||
FeeMinor int64 `db:"fee_minor" json:"-"`
|
||||
CashDeltaMinor int64 `db:"cash_delta_minor" json:"-"`
|
||||
RealizedMinor int64 `db:"realized_minor" json:"-"`
|
||||
Price string `json:"price"`
|
||||
Fee string `json:"fee"`
|
||||
CashDelta string `json:"cash_delta"`
|
||||
RealizedPnL string `json:"realized_pnl"`
|
||||
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
|
||||
SellableAt *time.Time `db:"sellable_at" json:"sellable_at"`
|
||||
Note string `db:"note" json:"note"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
Reversed bool `json:"reversed"`
|
||||
}
|
||||
|
||||
func (TradeFill) TableName() string { return "veyra_trade_fill" }
|
||||
|
||||
type TradeReversal struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
FillID string `db:"fill_id" json:"fill_id"`
|
||||
ActorID string `db:"actor_id" json:"actor_id"`
|
||||
Reason string `db:"reason" json:"reason"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (TradeReversal) TableName() string { return "veyra_trade_reversal" }
|
||||
|
||||
type PositionLot struct {
|
||||
ID string `db:"id"`
|
||||
AccountID string `db:"account_id"`
|
||||
BuyFillID string `db:"buy_fill_id"`
|
||||
Symbol string `db:"symbol"`
|
||||
Exchange string `db:"exchange"`
|
||||
RemainingQuantity int64 `db:"remaining_quantity"`
|
||||
RemainingCostMinor int64 `db:"remaining_cost_minor"`
|
||||
SellableAt time.Time `db:"sellable_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
func (PositionLot) TableName() string { return "veyra_position_lot" }
|
||||
|
||||
type FillAllocation struct {
|
||||
ID string `db:"id"`
|
||||
SellFillID string `db:"sell_fill_id"`
|
||||
LotID string `db:"lot_id"`
|
||||
Quantity int64 `db:"quantity"`
|
||||
CostMinor int64 `db:"cost_minor"`
|
||||
}
|
||||
|
||||
func (FillAllocation) TableName() string { return "veyra_fill_allocation" }
|
||||
|
||||
type PositionSummary struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Exchange string `json:"exchange"`
|
||||
Name string `json:"name"`
|
||||
Quantity string `json:"quantity"`
|
||||
SellableQuantity string `json:"sellable_quantity"`
|
||||
CostBasis string `json:"cost_basis"`
|
||||
MarketValue *string `json:"market_value"`
|
||||
UnrealizedPnL *string `json:"unrealized_pnl"`
|
||||
}
|
||||
type LedgerReview struct {
|
||||
TradeCount int64 `json:"trade_count"`
|
||||
SellCount int64 `json:"sell_count"`
|
||||
ProfitableSellCount int64 `json:"profitable_sell_count"`
|
||||
LosingSellCount int64 `json:"losing_sell_count"`
|
||||
BuyOutflow string `json:"buy_outflow"`
|
||||
SellInflow string `json:"sell_inflow"`
|
||||
TotalFees string `json:"total_fees"`
|
||||
}
|
||||
type PortfolioSummary struct {
|
||||
Review LedgerReview `json:"review"`
|
||||
AccountID string `json:"account_id"`
|
||||
CashBalance string `json:"cash_balance"`
|
||||
RealizedPnL string `json:"realized_pnl"`
|
||||
Positions []PositionSummary `json:"positions"`
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type PlanPreview struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
AccountID string `db:"account_id" json:"account_id"`
|
||||
CandidateRevisionID string `db:"candidate_revision_id" json:"candidate_revision_id"`
|
||||
CandidateVersion int64 `db:"candidate_version" json:"candidate_version"`
|
||||
Quantity int64 `db:"quantity" json:"quantity,string"`
|
||||
FeeBudgetMinor int64 `db:"fee_budget_minor" json:"-"`
|
||||
ReservedMinor int64 `db:"reserved_minor" json:"-"`
|
||||
ReservedAmount string `json:"reserved_amount"`
|
||||
FeeBudget string `json:"fee_budget"`
|
||||
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (PlanPreview) TableName() string { return "veyra_plan_preview" }
|
||||
|
||||
type TradePlan struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
PreviewID string `db:"preview_id" json:"preview_id"`
|
||||
AccountID string `db:"account_id" json:"account_id"`
|
||||
CandidateRevisionID string `db:"candidate_revision_id" json:"candidate_revision_id"`
|
||||
CandidateVersion int64 `db:"candidate_version" json:"candidate_version"`
|
||||
Quantity int64 `db:"quantity" json:"quantity,string"`
|
||||
FilledQuantity int64 `db:"filled_quantity" json:"filled_quantity,string"`
|
||||
FeeBudgetMinor int64 `db:"fee_budget_minor" json:"-"`
|
||||
ReservedMinor int64 `db:"reserved_minor" json:"-"`
|
||||
ReservedAmount string `json:"reserved_amount"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Version int64 `db:"version" json:"version"`
|
||||
ValidUntil time.Time `db:"valid_until" json:"valid_until"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
CandidateContent CandidateContent `json:"candidate_content"`
|
||||
}
|
||||
|
||||
func (TradePlan) TableName() string { return "veyra_trade_plan" }
|
||||
|
||||
type PlanBudget struct {
|
||||
AccountID string `json:"account_id"`
|
||||
CashBalance string `json:"cash_balance"`
|
||||
ReservedAmount string `json:"reserved_amount"`
|
||||
AvailableAmount string `json:"available_amount"`
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package repository
|
||||
|
||||
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"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type BacktestRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewBacktestRepository(pool *pgxpool.Pool) *BacktestRepository { return &BacktestRepository{pool} }
|
||||
|
||||
const backtestColumns = `id,strategy_version_id,status,input,input_hash,result,failure,lease_id,created_at,finished_at`
|
||||
|
||||
func scanBacktest(row pgx.Row) (model.BacktestRun, error) {
|
||||
var v model.BacktestRun
|
||||
err := row.Scan(&v.ID, &v.StrategyVersionID, &v.Status, &v.Input, &v.InputHash, &v.Result, &v.Failure, &v.LeaseID, &v.CreatedAt, &v.FinishedAt)
|
||||
return v, mapError(err)
|
||||
}
|
||||
func (r *BacktestRepository) Detail(ctx context.Context, id string) (model.BacktestRun, error) {
|
||||
return scanBacktest(r.pool.QueryRow(ctx, `SELECT `+backtestColumns+` FROM veyra_backtest_run WHERE id=$1`, id))
|
||||
}
|
||||
func (r *BacktestRepository) List(ctx context.Context) ([]model.BacktestRun, error) {
|
||||
rows, e := r.pool.Query(ctx, `SELECT `+backtestColumns+` FROM veyra_backtest_run ORDER BY created_at DESC,id DESC LIMIT 100`)
|
||||
if e != nil {
|
||||
return nil, mapError(e)
|
||||
}
|
||||
defer rows.Close()
|
||||
list := make([]model.BacktestRun, 0)
|
||||
for rows.Next() {
|
||||
v, e := scanBacktest(rows)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
v.Input = nil
|
||||
v.Result = nil
|
||||
list = append(list, v)
|
||||
}
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
func (r *BacktestRepository) Create(ctx context.Context, input model.BacktestInput, actor, requestID string) (model.BacktestRun, error) {
|
||||
tx, e := r.pool.Begin(ctx)
|
||||
if e != nil {
|
||||
return model.BacktestRun{}, mapError(e)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
var p model.StrategyParameters
|
||||
var raw []byte
|
||||
if e := tx.QueryRow(ctx, `SELECT parameters FROM veyra_strategy_version WHERE id=$1`, input.StrategyVersionID).Scan(&raw); e != nil {
|
||||
return model.BacktestRun{}, mapError(e)
|
||||
}
|
||||
if json.Unmarshal(raw, &p) != nil {
|
||||
return model.BacktestRun{}, apperror.ErrUnavailable
|
||||
}
|
||||
if len(input.Bars) < p.LongWindow+2 {
|
||||
return model.BacktestRun{}, apperror.ErrInvalidInput
|
||||
}
|
||||
data, e := json.Marshal(model.ResearchInput{BacktestInput: input, Parameters: p})
|
||||
if e != nil {
|
||||
return model.BacktestRun{}, apperror.ErrInvalidInput
|
||||
}
|
||||
hash := sha256.Sum256(data)
|
||||
id := uuid.NewString()
|
||||
_, e = tx.Exec(ctx, `INSERT INTO veyra_backtest_run(id,strategy_version_id,input,input_hash,author_id) VALUES($1,$2,$3,$4,$5)`, id, input.StrategyVersionID, data, hex.EncodeToString(hash[:]), actor)
|
||||
if e != nil {
|
||||
return model.BacktestRun{}, mapError(e)
|
||||
}
|
||||
if e := appendAudit(ctx, tx, actor, "BACKTEST_QUEUED", "backtest_run", id, requestID, map[string]any{"strategy_version_id": input.StrategyVersionID, "input_hash": hex.EncodeToString(hash[:]), "bars": len(input.Bars)}); e != nil {
|
||||
return model.BacktestRun{}, e
|
||||
}
|
||||
v, e := scanBacktest(tx.QueryRow(ctx, `SELECT `+backtestColumns+` FROM veyra_backtest_run WHERE id=$1`, id))
|
||||
if e != nil {
|
||||
return v, e
|
||||
}
|
||||
return v, mapError(tx.Commit(ctx))
|
||||
}
|
||||
func (r *BacktestRepository) Claim(ctx context.Context) (model.BacktestRun, error) {
|
||||
return scanBacktest(r.pool.QueryRow(ctx, `WITH next AS (SELECT id FROM veyra_backtest_run WHERE status='PENDING' OR (status='RUNNING' AND lease_expires_at<now()) ORDER BY created_at,id LIMIT 1 FOR UPDATE SKIP LOCKED) UPDATE veyra_backtest_run r SET status='RUNNING',lease_id=$1,lease_expires_at=now()+interval '60 seconds' FROM next WHERE r.id=next.id RETURNING r.id,r.strategy_version_id,r.status,r.input,r.input_hash,r.result,r.failure,r.lease_id,r.created_at,r.finished_at`, uuid.NewString()))
|
||||
}
|
||||
func (r *BacktestRepository) Finish(ctx context.Context, job model.BacktestRun, result json.RawMessage, failure string) error {
|
||||
status := "COMPLETED"
|
||||
if failure != "" {
|
||||
status = "FAILED"
|
||||
result = nil
|
||||
}
|
||||
tag, e := r.pool.Exec(ctx, `UPDATE veyra_backtest_run SET status=$3,result=$4,failure=$5,lease_id=NULL,lease_expires_at=NULL,finished_at=now() WHERE id=$1 AND lease_id=$2 AND status='RUNNING' AND lease_expires_at>now()`, job.ID, job.LeaseID, status, result, failure)
|
||||
if e != nil {
|
||||
return mapError(e)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return apperror.ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type CandidateRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
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),'')`
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return r, mapError(err)
|
||||
}
|
||||
if err := json.Unmarshal(snapshot, &r.Snapshot); 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`
|
||||
rows, err := r.pool.Query(ctx, query, admin, status, limit)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
list := make([]model.CandidateRevision, 0)
|
||||
for rows.Next() {
|
||||
v, err := scanCandidate(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, v)
|
||||
}
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
func (r *CandidateRepository) Detail(ctx context.Context, id string, admin bool) (model.CandidateDetail, error) {
|
||||
revision, err := scanCandidate(r.pool.QueryRow(ctx, `SELECT `+candidateColumns+candidateJoin+` WHERE r.id=$1 AND ($2 OR r.published_at IS NOT NULL)`, id, admin))
|
||||
detail := model.CandidateDetail{CandidateRevision: revision, Reviews: make([]model.CandidateReview, 0)}
|
||||
if err != nil {
|
||||
return detail, err
|
||||
}
|
||||
// App readers receive the publication, not internal reviewer identities or deliberations.
|
||||
if !admin {
|
||||
return detail, nil
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT id,revision_id,reviewer_id,decision,input_version,reason,created_at FROM veyra_candidate_review WHERE revision_id=$1 ORDER BY input_version`, id)
|
||||
if err != nil {
|
||||
return detail, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var v model.CandidateReview
|
||||
if err := rows.Scan(&v.ID, &v.RevisionID, &v.ReviewerID, &v.Decision, &v.InputVersion, &v.Reason, &v.CreatedAt); err != nil {
|
||||
return detail, mapError(err)
|
||||
}
|
||||
detail.Reviews = append(detail.Reviews, v)
|
||||
}
|
||||
return detail, mapError(rows.Err())
|
||||
}
|
||||
|
||||
// All mutations lock the candidate aggregate before its revisions, with the same lock order.
|
||||
func lockCandidate(ctx context.Context, tx pgx.Tx, source string) (model.CandidateRevision, error) {
|
||||
var parent string
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return scanCandidate(tx.QueryRow(ctx, `SELECT `+candidateColumns+candidateJoin+` WHERE r.id=$1 FOR UPDATE OF r`, source))
|
||||
}
|
||||
func (r *CandidateRepository) Create(ctx context.Context, input model.CandidateDraftInput, actor, requestID, source string, expected int64) (model.CandidateRevision, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return model.CandidateRevision{}, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
parent, revision := uuid.NewString(), 1
|
||||
if source == "" {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_candidate(id,latest_revision) VALUES($1,1)`, parent)
|
||||
} else {
|
||||
previous, e := lockCandidate(ctx, tx, source)
|
||||
if e != nil {
|
||||
return model.CandidateRevision{}, e
|
||||
}
|
||||
if previous.Version != expected || !previous.IsLatest || previous.Status == "PENDING_REVIEW" {
|
||||
return model.CandidateRevision{}, apperror.ErrConflict
|
||||
}
|
||||
parent, revision = previous.CandidateID, previous.Revision+1
|
||||
_, err = tx.Exec(ctx, `UPDATE veyra_candidate SET latest_revision=$2 WHERE id=$1`, parent, revision)
|
||||
}
|
||||
if err != nil {
|
||||
return model.CandidateRevision{}, mapError(err)
|
||||
}
|
||||
id := uuid.NewString()
|
||||
snapshot, err := json.Marshal(input.Content)
|
||||
if err != nil {
|
||||
return model.CandidateRevision{}, apperror.ErrInvalidInput
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_candidate_revision(id,candidate_id,revision,status,data_mode,snapshot,author_id,observed_at,valid_until) VALUES($1,$2,$3,'DRAFT','simulation',$4,$5,$6,$7)`, id, parent, revision, snapshot, actor, input.ObservedAt, input.ValidUntil)
|
||||
if err != nil {
|
||||
return model.CandidateRevision{}, mapError(err)
|
||||
}
|
||||
if err := appendAudit(ctx, tx, actor, "CANDIDATE_DRAFT_CREATED", "candidate_revision", id, requestID, map[string]any{"revision": revision, "source_revision_id": source}); err != nil {
|
||||
return model.CandidateRevision{}, err
|
||||
}
|
||||
created, err := scanCandidate(tx.QueryRow(ctx, `SELECT `+candidateColumns+candidateJoin+` WHERE r.id=$1`, id))
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
return created, mapError(tx.Commit(ctx))
|
||||
}
|
||||
func appendCandidateEvent(ctx context.Context, tx pgx.Tx, r model.CandidateRevision, eventType string, details any) error {
|
||||
payload, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return apperror.ErrUnavailable
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_outbox_event(id,aggregate_type,aggregate_id,aggregate_version,event_type,payload) VALUES($1,'candidate_revision',$2,$3,$4,$5)`, uuid.NewString(), r.ID, r.Version, eventType, payload)
|
||||
return mapError(err)
|
||||
}
|
||||
func (r *CandidateRepository) Transition(ctx context.Context, id, action string, input model.CandidateActionInput, actor, requestID string) (model.CandidateRevision, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return model.CandidateRevision{}, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
revision, err := lockCandidate(ctx, tx, id)
|
||||
if err != nil {
|
||||
return revision, err
|
||||
}
|
||||
if revision.Version != input.ExpectedVersion {
|
||||
return revision, apperror.ErrConflict
|
||||
}
|
||||
status, err := model.NextStatus(revision, action, time.Now().UTC())
|
||||
if err != nil {
|
||||
return revision, err
|
||||
}
|
||||
if action == "PUBLISH" {
|
||||
// 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 {
|
||||
return revision, err
|
||||
}
|
||||
if err == nil {
|
||||
if err := appendCandidateEvent(ctx, tx, previous, "candidate.superseded", map[string]any{"replacement_revision_id": id}); err != nil {
|
||||
return revision, err
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE veyra_candidate SET published_revision_id=$2 WHERE id=$1`, revision.CandidateID, id)
|
||||
if err != nil {
|
||||
return revision, mapError(err)
|
||||
}
|
||||
} else if action == "WITHDRAW" {
|
||||
_, err = tx.Exec(ctx, `UPDATE veyra_candidate SET published_revision_id=NULL WHERE id=$1`, revision.CandidateID)
|
||||
if err != nil {
|
||||
return revision, mapError(err)
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE veyra_candidate_revision SET status=$2::varchar(24),version=version+1,updated_at=now(),published_at=CASE WHEN $2::varchar(24)='PUBLISHED' THEN now() ELSE published_at END WHERE id=$1`, id, status)
|
||||
if err != nil {
|
||||
return revision, mapError(err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_candidate_review(id,revision_id,reviewer_id,decision,input_version,reason) VALUES($1,$2,$3,$4,$5,$6)`, uuid.NewString(), id, actor, action, input.ExpectedVersion, input.Reason)
|
||||
if err != nil {
|
||||
return revision, mapError(err)
|
||||
}
|
||||
if err := appendAudit(ctx, tx, actor, "CANDIDATE_"+action, "candidate_revision", id, requestID, map[string]any{"reason": input.Reason, "input_version": input.ExpectedVersion, "status": status}); err != nil {
|
||||
return revision, err
|
||||
}
|
||||
revision, err = scanCandidate(tx.QueryRow(ctx, `SELECT `+candidateColumns+candidateJoin+` WHERE r.id=$1`, id))
|
||||
if err != nil {
|
||||
return revision, err
|
||||
}
|
||||
if action == "PUBLISH" || action == "WITHDRAW" {
|
||||
eventType := "candidate.published"
|
||||
if action == "WITHDRAW" {
|
||||
eventType = "candidate.withdrawn"
|
||||
}
|
||||
if err := appendCandidateEvent(ctx, tx, revision, eventType, map[string]any{"revision_id": id, "version": revision.Version, "reason": input.Reason}); err != nil {
|
||||
return revision, err
|
||||
}
|
||||
}
|
||||
return revision, mapError(tx.Commit(ctx))
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
@@ -45,9 +46,25 @@ func (r *IdentityRepository) FindCredential(ctx context.Context, login string) (
|
||||
return u, c, mapError(err)
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) CreateSession(ctx context.Context, s model.Session) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO veyra_session(id,user_id,token_hash,audience,expires_at) VALUES($1,$2,$3,$4,$5)`, s.ID, s.UserID, s.TokenHash, s.Audience, s.ExpiresAt)
|
||||
return mapError(err)
|
||||
func (r *IdentityRepository) CreateSession(ctx context.Context, s model.Session, verifiedPasswordHash string) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
var userID string
|
||||
err = tx.QueryRow(ctx, `SELECT u.id FROM veyra_user u JOIN veyra_credential c ON c.user_id=u.id WHERE u.id=$1 AND c.password_hash=$2 AND u.status='ACTIVE' AND (($3='admin' AND u.role='ADMIN') OR ($3='app' AND u.role='USER')) FOR SHARE OF u,c`, s.UserID, verifiedPasswordHash, s.Audience).Scan(&userID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return apperror.ErrUnauthenticated
|
||||
}
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_session(id,user_id,token_hash,audience,expires_at) VALUES($1,$2,$3,$4,$5)`, s.ID, s.UserID, s.TokenHash, s.Audience, s.ExpiresAt)
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
return mapError(tx.Commit(ctx))
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) FindSessionUser(ctx context.Context, hash, audience string, now time.Time) (model.User, error) {
|
||||
@@ -80,3 +97,33 @@ func (r *IdentityRepository) ListUsers(ctx context.Context, limit int) ([]model.
|
||||
}
|
||||
return users, mapError(rows.Err())
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) ChangeAdminCredential(ctx context.Context, previousLogin, login, passwordHash string, now time.Time) (string, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
var userID, role, status string
|
||||
err = tx.QueryRow(ctx, `SELECT u.id,u.role,u.status FROM veyra_user u JOIN veyra_credential c ON c.user_id=u.id WHERE c.login_name=$1 FOR UPDATE OF u,c`, previousLogin).Scan(&userID, &role, &status)
|
||||
if err != nil {
|
||||
return "", mapError(err)
|
||||
}
|
||||
if role != "ADMIN" || status != "ACTIVE" {
|
||||
return "", apperror.ErrForbidden
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE veyra_credential SET login_name=$2,password_hash=$3 WHERE user_id=$1`, userID, login, passwordHash); err != nil {
|
||||
return "", mapError(err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `UPDATE veyra_session SET revoked_at=$2 WHERE user_id=$1 AND revoked_at IS NULL`, userID, now)
|
||||
if err != nil {
|
||||
return "", mapError(err)
|
||||
}
|
||||
if err := appendAudit(ctx, tx, userID, "ADMIN_CREDENTIAL_CHANGED", "user", userID, uuid.NewString(), map[string]any{"sessions_revoked": tag.RowsAffected()}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", mapError(err)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MarketRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewMarketRepository(pool *pgxpool.Pool) *MarketRepository { return &MarketRepository{pool} }
|
||||
func (r *MarketRepository) List(ctx context.Context, query string) ([]model.Instrument, error) {
|
||||
rows, e := r.pool.Query(ctx, `SELECT id,symbol,exchange,name,price_cents,observed_at,received_at,source_id,source_version FROM veyra_instrument WHERE symbol LIKE $1 OR name LIKE $1 ORDER BY (price_cents>0) DESC,symbol LIMIT 100`, "%"+query+"%")
|
||||
if e != nil {
|
||||
return nil, mapError(e)
|
||||
}
|
||||
defer rows.Close()
|
||||
list := make([]model.Instrument, 0)
|
||||
for rows.Next() {
|
||||
var v model.Instrument
|
||||
if e := rows.Scan(&v.ID, &v.Symbol, &v.Exchange, &v.Name, &v.PriceCents, &v.ObservedAt, &v.ReceivedAt, &v.SourceID, &v.SourceVersion); e != nil {
|
||||
return nil, mapError(e)
|
||||
}
|
||||
v.Price = model.FormatMoney(v.PriceCents)
|
||||
list = append(list, v)
|
||||
}
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
func (r *MarketRepository) Save(ctx context.Context, c model.ProviderConfig, list []model.Instrument, started time.Time, message string) error {
|
||||
tx, e := r.pool.Begin(ctx)
|
||||
if e != nil {
|
||||
return mapError(e)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
var version int64
|
||||
var enabled bool
|
||||
if e := tx.QueryRow(ctx, `SELECT version,enabled FROM veyra_provider_config WHERE id=$1 FOR SHARE`, c.ID).Scan(&version, &enabled); e != nil {
|
||||
return mapError(e)
|
||||
}
|
||||
if !enabled || version != c.Version {
|
||||
return apperror.ErrConflict
|
||||
}
|
||||
status := "COMPLETED"
|
||||
if message != "" {
|
||||
status = "FAILED"
|
||||
list = nil
|
||||
}
|
||||
for _, v := range list {
|
||||
_, e := tx.Exec(ctx, `INSERT INTO veyra_instrument(id,symbol,exchange,name,price_cents,observed_at,source_id,source_version) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(exchange,symbol) DO UPDATE SET name=excluded.name,price_cents=excluded.price_cents,observed_at=excluded.observed_at,received_at=now(),source_id=excluded.source_id,source_version=excluded.source_version WHERE veyra_instrument.observed_at<=excluded.observed_at`, uuid.NewString(), v.Symbol, v.Exchange, v.Name, v.PriceCents, v.ObservedAt, c.ID, c.Version)
|
||||
if e != nil {
|
||||
return mapError(e)
|
||||
}
|
||||
}
|
||||
_, 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)
|
||||
}
|
||||
return mapError(tx.Commit(ctx))
|
||||
}
|
||||
func (r *MarketRepository) Tasks(ctx context.Context) ([]model.MarketSync, error) {
|
||||
rows, e := r.pool.Query(ctx, `SELECT id,source_id,source_version,instrument_count,status,message,started_at,finished_at FROM veyra_market_sync ORDER BY finished_at DESC,id DESC LIMIT 20`)
|
||||
if e != nil {
|
||||
return nil, mapError(e)
|
||||
}
|
||||
defer rows.Close()
|
||||
list := make([]model.MarketSync, 0)
|
||||
for rows.Next() {
|
||||
var v model.MarketSync
|
||||
if e := rows.Scan(&v.ID, &v.SourceID, &v.SourceVersion, &v.InstrumentCount, &v.Status, &v.Message, &v.StartedAt, &v.FinishedAt); e != nil {
|
||||
return nil, mapError(e)
|
||||
}
|
||||
list = append(list, v)
|
||||
}
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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 NotificationRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewNotificationRepository(pool *pgxpool.Pool) *NotificationRepository {
|
||||
return &NotificationRepository{pool}
|
||||
}
|
||||
func (r *NotificationRepository) List(ctx context.Context, user string) ([]model.Notification, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT id,user_id,event_id,plan_id,title,body,read_at,created_at FROM veyra_notification WHERE user_id=$1 ORDER BY created_at DESC,id DESC LIMIT 100`, user)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
list := make([]model.Notification, 0)
|
||||
for rows.Next() {
|
||||
var n model.Notification
|
||||
if err := rows.Scan(&n.ID, &n.UserID, &n.EventID, &n.PlanID, &n.Title, &n.Body, &n.ReadAt, &n.CreatedAt); err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
list = append(list, n)
|
||||
}
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
func (r *NotificationRepository) MarkRead(ctx context.Context, user, id string) error {
|
||||
tag, err := r.pool.Exec(ctx, `UPDATE veyra_notification SET read_at=COALESCE(read_at,now()) WHERE id=$1 AND user_id=$2`, id, user)
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return apperror.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *NotificationRepository) Tasks(ctx context.Context) ([]model.NotificationTask, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT e.id,e.aggregate_type,e.aggregate_id,e.aggregate_version,e.event_type,e.payload,e.created_at,e.processed_at,(SELECT count(*)::integer FROM veyra_notification n WHERE n.event_id=e.id) FROM veyra_outbox_event e ORDER BY created_at DESC,id DESC LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
list := make([]model.NotificationTask, 0)
|
||||
for rows.Next() {
|
||||
var t model.NotificationTask
|
||||
if err := rows.Scan(&t.ID, &t.AggregateType, &t.AggregateID, &t.AggregateVersion, &t.EventType, &t.Payload, &t.CreatedAt, &t.ProcessedAt, &t.RecipientCount); err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
list = append(list, t)
|
||||
}
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
|
||||
const quoteColumns = `id,symbol,exchange,data_mode,price_minor,observed_at,version,created_at,updated_at`
|
||||
|
||||
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)
|
||||
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`)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
list := make([]model.QuoteSnapshot, 0)
|
||||
for rows.Next() {
|
||||
q, err := scanQuote(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, q)
|
||||
}
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
func (r *NotificationRepository) SaveQuote(ctx context.Context, q model.QuoteSnapshot, actor, requestID string) (model.QuoteSnapshot, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return q, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
q, err = scanQuote(tx.QueryRow(ctx, `INSERT INTO veyra_quote_snapshot(id,symbol,exchange,data_mode,price_minor,observed_at,version) VALUES($1,$2,$3,'simulation',$4,$5,1) 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,updated_at=now() WHERE EXCLUDED.observed_at>veyra_quote_snapshot.observed_at RETURNING `+quoteColumns, uuid.NewString(), q.Symbol, q.Exchange, q.PriceMinor, q.ObservedAt))
|
||||
if err == apperror.ErrNotFound {
|
||||
return q, apperror.ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return q, err
|
||||
}
|
||||
payload, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
return q, apperror.ErrUnavailable
|
||||
}
|
||||
_, 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)
|
||||
if err != nil {
|
||||
return q, mapError(err)
|
||||
}
|
||||
if err := appendAudit(ctx, tx, actor, "SIMULATION_QUOTE_RECORDED", "quote_snapshot", q.ID, requestID, map[string]any{"symbol": q.Symbol, "exchange": q.Exchange, "version": q.Version}); err != nil {
|
||||
return q, err
|
||||
}
|
||||
return q, mapError(tx.Commit(ctx))
|
||||
}
|
||||
func insertNotification(ctx context.Context, tx pgx.Tx, user, event string, plan *string, title, body string) error {
|
||||
_, err := tx.Exec(ctx, `INSERT INTO veyra_notification(id,user_id,event_id,plan_id,title,body) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, uuid.NewString(), user, event, plan, title, body)
|
||||
return mapError(err)
|
||||
}
|
||||
func processQuote(ctx context.Context, tx pgx.Tx, event model.OutboxEvent) error {
|
||||
var payload model.QuoteSnapshot
|
||||
if json.Unmarshal(event.Payload, &payload) != nil {
|
||||
return apperror.ErrUnavailable
|
||||
}
|
||||
// A stale or superseded snapshot cannot generate a delayed buy reminder.
|
||||
current, err := scanQuote(tx.QueryRow(ctx, `SELECT `+quoteColumns+` FROM veyra_quote_snapshot WHERE id=$1 FOR SHARE`, event.AggregateID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.Version != event.AggregateVersion || time.Since(current.ObservedAt) > time.Minute || current.ObservedAt.After(time.Now().Add(time.Minute)) {
|
||||
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)
|
||||
}
|
||||
type target struct{ plan, revision, user string }
|
||||
targets := make([]target, 0)
|
||||
for rows.Next() {
|
||||
var t target
|
||||
if err := rows.Scan(&t.plan, &t.revision, &t.user); err != nil {
|
||||
rows.Close()
|
||||
return mapError(err)
|
||||
}
|
||||
targets = append(targets, t)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
for _, target := range targets {
|
||||
publication, err := lockCandidate(ctx, tx, target.revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !publication.IsActivePublication {
|
||||
continue
|
||||
}
|
||||
var status string
|
||||
var valid time.Time
|
||||
if err := tx.QueryRow(ctx, `SELECT status,valid_until FROM veyra_trade_plan WHERE id=$1 FOR UPDATE`, target.plan).Scan(&status, &valid); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
if (status != "ACTIVE" && status != "PART_FILLED") || !valid.After(time.Now()) {
|
||||
continue
|
||||
}
|
||||
low, e1 := model.ParseMoney(publication.Snapshot.ReferencePlan.EntryLow)
|
||||
high, e2 := model.ParseMoney(publication.Snapshot.ReferencePlan.EntryHigh)
|
||||
if e1 != nil || e2 != nil {
|
||||
return apperror.ErrUnavailable
|
||||
}
|
||||
if current.PriceMinor < low || current.PriceMinor > high {
|
||||
continue
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `INSERT INTO veyra_plan_trigger(id,plan_id,quote_snapshot_id,quote_version) VALUES($1,$2,$3,$4) ON CONFLICT(plan_id) DO NOTHING`, uuid.NewString(), target.plan, current.ID, current.Version)
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
continue
|
||||
}
|
||||
body := fmt.Sprintf("模拟价格 ¥%s 进入 %s 的参考区间。观察时间 %s。请打开计划核对当前状态;此提醒不代表成交。", 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
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func processCandidateInvalidation(ctx context.Context, tx pgx.Tx, event model.OutboxEvent) error {
|
||||
rows, err := tx.Query(ctx, `SELECT p.id,a.user_id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id WHERE p.candidate_revision_id=$1 AND p.status IN ('ACTIVE','PART_FILLED') ORDER BY p.id`, event.AggregateID)
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
type target struct{ plan, user string }
|
||||
targets := make([]target, 0)
|
||||
for rows.Next() {
|
||||
var t target
|
||||
if err := rows.Scan(&t.plan, &t.user); err != nil {
|
||||
rows.Close()
|
||||
return mapError(err)
|
||||
}
|
||||
targets = append(targets, t)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
for _, t := range targets {
|
||||
tag, err := tx.Exec(ctx, `UPDATE veyra_trade_plan SET status='INVALIDATED',reserved_minor=0,version=version+1,updated_at=now() WHERE id=$1 AND status IN ('ACTIVE','PART_FILLED')`, t.plan)
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
reason := "对应候选已撤回或被新版替代,保留预算已释放。已记录成交与持仓保留。"
|
||||
if err := insertNotification(ctx, tx, t.user, event.ID, &t.plan, "计划已失效", reason); err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
if err := appendAudit(ctx, tx, t.user, "PLAN_INVALIDATED", "trade_plan", t.plan, event.ID, map[string]any{"cause": event.EventType}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *NotificationRepository) ProcessNext(ctx context.Context) (bool, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return false, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
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`)
|
||||
if err != nil {
|
||||
return false, mapError(err)
|
||||
}
|
||||
users := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var user string
|
||||
if err := rows.Scan(&user); err != nil {
|
||||
rows.Close()
|
||||
return false, mapError(err)
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, mapError(err)
|
||||
}
|
||||
for _, user := range users {
|
||||
if err := invalidatePlans(ctx, tx, user); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return len(users) > 0, mapError(tx.Commit(ctx))
|
||||
}
|
||||
if err != nil {
|
||||
return false, mapError(err)
|
||||
}
|
||||
switch event.EventType {
|
||||
case "quote.observed":
|
||||
err = processQuote(ctx, tx, event)
|
||||
case "candidate.withdrawn", "candidate.superseded":
|
||||
err = processCandidateInvalidation(ctx, tx, event)
|
||||
case "plan.invalidated":
|
||||
var user string
|
||||
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, "计划已失效", "对应候选已失效或计划已过期,保留预算已释放。已记录成交与持仓保留。")
|
||||
}
|
||||
case "candidate.published", "position.changed":
|
||||
default:
|
||||
return false, apperror.ErrInvalidInput
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE veyra_outbox_event SET processed_at=now() WHERE id=$1`, event.ID); err != nil {
|
||||
return false, mapError(err)
|
||||
}
|
||||
return true, mapError(tx.Commit(ctx))
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
)
|
||||
|
||||
type ProviderRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewProviderRepository(pool *pgxpool.Pool) *ProviderRepository {
|
||||
return &ProviderRepository{pool: pool}
|
||||
}
|
||||
|
||||
const providerColumns = `id,config_key,name,base_url,model_name,enabled,timeout_seconds,encrypted_api_key,version,last_test_status,last_tested_at,last_test_message,updated_by,created_at,updated_at,wire_api,model_catalog_url,disable_response_storage,requires_openai_auth,market_adapter,history_base_url,quote_base_url`
|
||||
|
||||
func scanProvider(row pgx.Row) (model.ProviderConfig, error) {
|
||||
var c model.ProviderConfig
|
||||
err := row.Scan(&c.ID, &c.ConfigKey, &c.Name, &c.BaseURL, &c.ModelName, &c.Enabled, &c.TimeoutSeconds, &c.EncryptedAPIKey, &c.Version, &c.LastTestStatus, &c.LastTestedAt, &c.LastTestMessage, &c.UpdatedBy, &c.CreatedAt, &c.UpdatedAt, &c.WireAPI, &c.ModelCatalogURL, &c.DisableResponseStorage, &c.RequiresOpenAIAuth, &c.MarketAdapter, &c.HistoryBaseURL, &c.QuoteBaseURL)
|
||||
c.HasAPIKey = len(c.EncryptedAPIKey) > 0
|
||||
return c, mapError(err)
|
||||
}
|
||||
func (r *ProviderRepository) Get(ctx context.Context, key string) (model.ProviderConfig, error) {
|
||||
return scanProvider(r.pool.QueryRow(ctx, `SELECT `+providerColumns+` FROM veyra_provider_config WHERE config_key=$1`, key))
|
||||
}
|
||||
func (r *ProviderRepository) List(ctx context.Context) ([]model.ProviderConfig, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+providerColumns+` FROM veyra_provider_config ORDER BY config_key`)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
configs := make([]model.ProviderConfig, 0)
|
||||
for rows.Next() {
|
||||
c, err := scanProvider(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configs = append(configs, c)
|
||||
}
|
||||
return configs, mapError(rows.Err())
|
||||
}
|
||||
|
||||
func (r *ProviderRepository) Save(ctx context.Context, c model.ProviderConfig, expected int64, actor, requestID string, secretChanged bool) (model.ProviderConfig, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return model.ProviderConfig{}, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
// A per-key lock also serializes two simultaneous first-time creates.
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, "veyra:provider:"+c.ConfigKey); err != nil {
|
||||
return model.ProviderConfig{}, mapError(err)
|
||||
}
|
||||
var version int64
|
||||
err = tx.QueryRow(ctx, `SELECT version FROM veyra_provider_config WHERE config_key=$1`, c.ConfigKey).Scan(&version)
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
return model.ProviderConfig{}, mapError(err)
|
||||
}
|
||||
if version != expected {
|
||||
return model.ProviderConfig{}, apperror.ErrConflict
|
||||
}
|
||||
c.Version = version + 1
|
||||
if version == 0 {
|
||||
c.ID = uuid.NewString()
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_provider_config(id,config_key,name,base_url,model_name,enabled,timeout_seconds,encrypted_api_key,version,last_test_status,updated_by,wire_api,model_catalog_url,disable_response_storage,requires_openai_auth,market_adapter,history_base_url,quote_base_url) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, c.ID, c.ConfigKey, c.Name, c.BaseURL, c.ModelName, c.Enabled, c.TimeoutSeconds, c.EncryptedAPIKey, c.Version, c.LastTestStatus, actor, c.WireAPI, c.ModelCatalogURL, c.DisableResponseStorage, c.RequiresOpenAIAuth, c.MarketAdapter, c.HistoryBaseURL, c.QuoteBaseURL)
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `UPDATE veyra_provider_config SET name=$2,base_url=$3,model_name=$4,enabled=$5,timeout_seconds=$6,encrypted_api_key=$7,version=$8,last_test_status=$9,last_tested_at=NULL,last_test_message='',updated_by=$10,wire_api=$11,model_catalog_url=$12,disable_response_storage=$13,requires_openai_auth=$14,market_adapter=$15,history_base_url=$16,quote_base_url=$17,updated_at=now() WHERE config_key=$1`, c.ConfigKey, c.Name, c.BaseURL, c.ModelName, c.Enabled, c.TimeoutSeconds, c.EncryptedAPIKey, c.Version, c.LastTestStatus, actor, c.WireAPI, c.ModelCatalogURL, c.DisableResponseStorage, c.RequiresOpenAIAuth, c.MarketAdapter, c.HistoryBaseURL, c.QuoteBaseURL)
|
||||
}
|
||||
if err != nil {
|
||||
return model.ProviderConfig{}, mapError(err)
|
||||
}
|
||||
c, err = scanProvider(tx.QueryRow(ctx, `SELECT `+providerColumns+` FROM veyra_provider_config WHERE config_key=$1`, c.ConfigKey))
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
snapshot, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return c, apperror.ErrUnavailable
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO veyra_provider_config_history(id,config_id,version,snapshot,encrypted_api_key,actor_id) VALUES($1,$2,$3,$4,$5,$6)`, uuid.NewString(), c.ID, c.Version, snapshot, c.EncryptedAPIKey, actor); err != nil {
|
||||
return c, mapError(err)
|
||||
}
|
||||
if err := appendAudit(ctx, tx, actor, "PROVIDER_CONFIG_SAVED", "provider_config", c.ID, requestID, map[string]any{"config_key": c.ConfigKey, "version": c.Version, "enabled": c.Enabled, "secret_changed": secretChanged}); err != nil {
|
||||
return c, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return c, mapError(err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r *ProviderRepository) RecordTest(ctx context.Context, c model.ProviderConfig, status, message, actor, requestID string) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
tag, err := tx.Exec(ctx, `UPDATE veyra_provider_config SET last_test_status=$3,last_test_message=$4,last_tested_at=$5 WHERE id=$1 AND version=$2`, c.ID, c.Version, status, message, time.Now().UTC())
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return apperror.ErrConflict
|
||||
}
|
||||
if err := appendAudit(ctx, tx, actor, "PROVIDER_TESTED", "provider_config", c.ID, requestID, map[string]any{"version": c.Version, "status": status}); err != nil {
|
||||
return err
|
||||
}
|
||||
return mapError(tx.Commit(ctx))
|
||||
}
|
||||
|
||||
func appendAudit(ctx context.Context, tx pgx.Tx, actor, action, objectType, objectID, requestID string, details any) error {
|
||||
encoded, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return apperror.ErrUnavailable
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_audit_event(id,actor_id,action,object_type,object_id,request_id,details) VALUES($1,$2,$3,$4,$5,$6,$7)`, uuid.NewString(), actor, action, objectType, objectID, requestID, encoded)
|
||||
return mapError(err)
|
||||
}
|
||||
func (r *ProviderRepository) Audit(ctx context.Context) ([]model.AuditEvent, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT id,actor_id,action,object_type,object_id,request_id,details,created_at FROM veyra_audit_event ORDER BY created_at DESC,id DESC LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
list := make([]model.AuditEvent, 0)
|
||||
for rows.Next() {
|
||||
var a model.AuditEvent
|
||||
if err := rows.Scan(&a.ID, &a.ActorID, &a.Action, &a.ObjectType, &a.ObjectID, &a.RequestID, &a.Details, &a.CreatedAt); err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
list = append(list, a)
|
||||
}
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type StrategyRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewStrategyRepository(pool *pgxpool.Pool) *StrategyRepository { return &StrategyRepository{pool} }
|
||||
|
||||
const strategyColumns = `id,code,name,status,latest_revision,active_version_id,version,created_at,updated_at`
|
||||
|
||||
func scanStrategy(row pgx.Row) (model.Strategy, error) {
|
||||
var s model.Strategy
|
||||
err := row.Scan(&s.ID, &s.Code, &s.Name, &s.Status, &s.LatestRevision, &s.ActiveVersionID, &s.Version, &s.CreatedAt, &s.UpdatedAt)
|
||||
return s, mapError(err)
|
||||
}
|
||||
func (r *StrategyRepository) List(ctx context.Context) ([]model.Strategy, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+strategyColumns+` FROM veyra_strategy ORDER BY updated_at DESC,id DESC LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
list := make([]model.Strategy, 0)
|
||||
for rows.Next() {
|
||||
s, err := scanStrategy(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, s)
|
||||
}
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
func (r *StrategyRepository) Detail(ctx context.Context, id string) (model.StrategyDetail, error) {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
|
||||
if err != nil {
|
||||
return model.StrategyDetail{}, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
return strategyDetail(ctx, tx, id)
|
||||
}
|
||||
func strategyDetail(ctx context.Context, tx pgx.Tx, id string) (model.StrategyDetail, error) {
|
||||
s, err := scanStrategy(tx.QueryRow(ctx, `SELECT `+strategyColumns+` FROM veyra_strategy WHERE id=$1`, id))
|
||||
detail := model.StrategyDetail{Strategy: s, Versions: make([]model.StrategyVersion, 0)}
|
||||
if err != nil {
|
||||
return detail, err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT id,strategy_id,revision,algorithm,parameters,description,author_id,created_at FROM veyra_strategy_version WHERE strategy_id=$1 ORDER BY revision DESC`, id)
|
||||
if err != nil {
|
||||
return detail, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var v model.StrategyVersion
|
||||
var parameters []byte
|
||||
if err := rows.Scan(&v.ID, &v.StrategyID, &v.Revision, &v.Algorithm, ¶meters, &v.Description, &v.AuthorID, &v.CreatedAt); err != nil {
|
||||
return detail, mapError(err)
|
||||
}
|
||||
if json.Unmarshal(parameters, &v.Parameters) != nil {
|
||||
return detail, apperror.ErrUnavailable
|
||||
}
|
||||
detail.Versions = append(detail.Versions, v)
|
||||
}
|
||||
return detail, mapError(rows.Err())
|
||||
}
|
||||
func (r *StrategyRepository) Save(ctx context.Context, id string, input service.StrategyInput, actor, requestID string) (model.StrategyDetail, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return model.StrategyDetail{}, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
revision := 1
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_strategy(id,code,name) VALUES($1,$2,$3)`, id, input.Code, input.Name)
|
||||
} else {
|
||||
s, e := scanStrategy(tx.QueryRow(ctx, `SELECT `+strategyColumns+` FROM veyra_strategy WHERE id=$1 FOR UPDATE`, id))
|
||||
if e != nil {
|
||||
return model.StrategyDetail{}, e
|
||||
}
|
||||
if s.Version != input.ExpectedVersion || s.Code != input.Code {
|
||||
return model.StrategyDetail{}, apperror.ErrConflict
|
||||
}
|
||||
revision = s.LatestRevision + 1
|
||||
_, err = tx.Exec(ctx, `UPDATE veyra_strategy SET name=$2,latest_revision=$3,version=version+1,updated_at=now() WHERE id=$1`, id, input.Name, revision)
|
||||
}
|
||||
if err != nil {
|
||||
return model.StrategyDetail{}, mapError(err)
|
||||
}
|
||||
parameters, err := json.Marshal(input.Parameters)
|
||||
if err != nil {
|
||||
return model.StrategyDetail{}, apperror.ErrInvalidInput
|
||||
}
|
||||
versionID := uuid.NewString()
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_strategy_version(id,strategy_id,revision,algorithm,parameters,description,author_id) VALUES($1,$2,$3,'sma_cross',$4,$5,$6)`, versionID, id, revision, parameters, input.Description, actor)
|
||||
if err != nil {
|
||||
return model.StrategyDetail{}, mapError(err)
|
||||
}
|
||||
if err := appendAudit(ctx, tx, actor, "STRATEGY_VERSION_CREATED", "strategy", id, requestID, map[string]any{"revision": revision, "version_id": versionID}); err != nil {
|
||||
return model.StrategyDetail{}, err
|
||||
}
|
||||
detail, err := strategyDetail(ctx, tx, id)
|
||||
if err != nil {
|
||||
return detail, err
|
||||
}
|
||||
return detail, mapError(tx.Commit(ctx))
|
||||
}
|
||||
func (r *StrategyRepository) Deploy(ctx context.Context, id, versionID, status string, expected int64, reason, actor, requestID string) (model.StrategyDetail, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return model.StrategyDetail{}, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
s, err := scanStrategy(tx.QueryRow(ctx, `SELECT `+strategyColumns+` FROM veyra_strategy WHERE id=$1 FOR UPDATE`, id))
|
||||
if err != nil {
|
||||
return model.StrategyDetail{}, err
|
||||
}
|
||||
if s.Version != expected {
|
||||
return model.StrategyDetail{}, apperror.ErrConflict
|
||||
}
|
||||
var selected *string
|
||||
if status == "ACTIVE" {
|
||||
var exists bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM veyra_strategy_version WHERE id=$1 AND strategy_id=$2)`, versionID, id).Scan(&exists); err != nil {
|
||||
return model.StrategyDetail{}, mapError(err)
|
||||
}
|
||||
if !exists {
|
||||
return model.StrategyDetail{}, apperror.ErrNotFound
|
||||
}
|
||||
selected = &versionID
|
||||
} else {
|
||||
selected = s.ActiveVersionID
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE veyra_strategy SET status=$2,active_version_id=$3,version=version+1,updated_at=now() WHERE id=$1`, id, status, selected)
|
||||
if err != nil {
|
||||
return model.StrategyDetail{}, mapError(err)
|
||||
}
|
||||
if err := appendAudit(ctx, tx, actor, "STRATEGY_DEPLOYMENT_CHANGED", "strategy", id, requestID, map[string]any{"status": status, "version_id": selected, "reason": reason}); err != nil {
|
||||
return model.StrategyDetail{}, err
|
||||
}
|
||||
detail, err := strategyDetail(ctx, tx, id)
|
||||
if err != nil {
|
||||
return detail, err
|
||||
}
|
||||
return detail, mapError(tx.Commit(ctx))
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
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"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TradeFillRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewTradeFillRepository(pool *pgxpool.Pool) *TradeFillRepository {
|
||||
return &TradeFillRepository{pool}
|
||||
}
|
||||
|
||||
const fillColumns = `f.id,f.account_id,f.plan_id,f.idempotency_key,f.input_hash,f.posted_sequence,f.side,f.symbol,f.exchange,f.name,f.quantity,f.price_minor,f.fee_minor,f.cash_delta_minor,f.realized_minor,f.occurred_at,f.sellable_at,f.note,f.created_at,EXISTS(SELECT 1 FROM veyra_trade_reversal x WHERE x.fill_id=f.id)`
|
||||
|
||||
func scanFill(row pgx.Row) (model.TradeFill, error) {
|
||||
var f model.TradeFill
|
||||
err := row.Scan(&f.ID, &f.AccountID, &f.PlanID, &f.IdempotencyKey, &f.InputHash, &f.PostedSequence, &f.Side, &f.Symbol, &f.Exchange, &f.Name, &f.Quantity, &f.PriceMinor, &f.FeeMinor, &f.CashDeltaMinor, &f.RealizedMinor, &f.OccurredAt, &f.SellableAt, &f.Note, &f.CreatedAt, &f.Reversed)
|
||||
f.Price = model.FormatMoney(f.PriceMinor)
|
||||
f.Fee = model.FormatMoney(f.FeeMinor)
|
||||
f.CashDelta = model.FormatMoney(f.CashDeltaMinor)
|
||||
f.RealizedPnL = model.FormatMoney(f.RealizedMinor)
|
||||
return f, mapError(err)
|
||||
}
|
||||
func appendPositionEvent(ctx context.Context, tx pgx.Tx, account, fill, aggregateType, eventType string) error {
|
||||
payload, err := json.Marshal(map[string]string{"account_id": account, "fill_id": fill})
|
||||
if err != nil {
|
||||
return apperror.ErrUnavailable
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_outbox_event(id,aggregate_type,aggregate_id,aggregate_version,event_type,payload) VALUES($1,$2,$3,1,$4,$5)`, uuid.NewString(), aggregateType, fill, eventType, payload)
|
||||
return mapError(err)
|
||||
}
|
||||
func (r *TradeFillRepository) Record(ctx context.Context, user string, fill model.TradeFill, requestID string) (model.TradeFill, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fill, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if err := lockSimulationAccount(ctx, tx, user, fill.AccountID); err != nil {
|
||||
return fill, err
|
||||
}
|
||||
previous, err := scanFill(tx.QueryRow(ctx, `SELECT `+fillColumns+` FROM veyra_trade_fill f WHERE account_id=$1 AND idempotency_key=$2`, fill.AccountID, fill.IdempotencyKey))
|
||||
if err == nil {
|
||||
if previous.InputHash != fill.InputHash {
|
||||
return fill, apperror.ErrConflict
|
||||
}
|
||||
return previous, nil
|
||||
}
|
||||
if err != apperror.ErrNotFound {
|
||||
return fill, err
|
||||
}
|
||||
var latest *time.Time
|
||||
if err := tx.QueryRow(ctx, `SELECT max(f.occurred_at) FROM veyra_trade_fill f WHERE f.account_id=$1 AND NOT EXISTS(SELECT 1 FROM veyra_trade_reversal x WHERE x.fill_id=f.id)`, fill.AccountID).Scan(&latest); err != nil {
|
||||
return fill, mapError(err)
|
||||
}
|
||||
if latest != nil && fill.OccurredAt.Before(*latest) {
|
||||
return fill, apperror.ErrChronology
|
||||
}
|
||||
var plan *model.TradePlan
|
||||
exclude := ""
|
||||
if fill.PlanID != nil {
|
||||
p, err := scanTradePlan(tx.QueryRow(ctx, `SELECT `+tradePlanColumns+tradePlanJoin+` WHERE p.id=$1 AND p.account_id=$2 FOR UPDATE OF p`, *fill.PlanID, fill.AccountID))
|
||||
if err != nil {
|
||||
return fill, err
|
||||
}
|
||||
if p.CandidateContent.Symbol != fill.Symbol || p.CandidateContent.Exchange != fill.Exchange || fill.Quantity > p.Quantity-p.FilledQuantity {
|
||||
return fill, apperror.ErrInvalidInput
|
||||
}
|
||||
low, e1 := model.ParseMoney(p.CandidateContent.ReferencePlan.EntryLow)
|
||||
high, e2 := model.ParseMoney(p.CandidateContent.ReferencePlan.EntryHigh)
|
||||
if e1 != nil || e2 != nil {
|
||||
return fill, apperror.ErrUnavailable
|
||||
}
|
||||
if (fill.PriceMinor < low || fill.PriceMinor > high || p.Status != "ACTIVE" && p.Status != "PART_FILLED") && fill.Note == "" {
|
||||
return fill, apperror.ErrInvalidInput
|
||||
}
|
||||
plan = &p
|
||||
exclude = p.ID
|
||||
}
|
||||
cash, reserved, err := budgetMinor(ctx, tx, fill.AccountID, exclude)
|
||||
if err != nil {
|
||||
return fill, err
|
||||
}
|
||||
if fill.Side == "BUY" && cash+fill.CashDeltaMinor < reserved {
|
||||
return fill, apperror.ErrInsufficientCash
|
||||
}
|
||||
allocations := make([]model.FillAllocation, 0)
|
||||
if fill.Side == "SELL" {
|
||||
rows, err := tx.Query(ctx, `SELECT l.id,l.remaining_quantity,l.remaining_cost_minor FROM veyra_position_lot l JOIN veyra_trade_fill f ON f.id=l.buy_fill_id WHERE l.account_id=$1 AND l.symbol=$2 AND l.exchange=$3 AND l.remaining_quantity>0 AND l.sellable_at<=$4 ORDER BY f.occurred_at,f.posted_sequence FOR UPDATE OF l`, fill.AccountID, fill.Symbol, fill.Exchange, fill.OccurredAt)
|
||||
if err != nil {
|
||||
return fill, mapError(err)
|
||||
}
|
||||
remaining, cost := fill.Quantity, int64(0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var qty, minor int64
|
||||
if err := rows.Scan(&id, &qty, &minor); err != nil {
|
||||
rows.Close()
|
||||
return fill, mapError(err)
|
||||
}
|
||||
if remaining == 0 {
|
||||
continue
|
||||
}
|
||||
take := qty
|
||||
if remaining < take {
|
||||
take = remaining
|
||||
}
|
||||
allocated := minor * take / qty
|
||||
allocations = append(allocations, model.FillAllocation{ID: uuid.NewString(), SellFillID: fill.ID, LotID: id, Quantity: take, CostMinor: allocated})
|
||||
remaining -= take
|
||||
cost += allocated
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return fill, mapError(err)
|
||||
}
|
||||
if remaining != 0 {
|
||||
return fill, apperror.ErrInsufficientQuantity
|
||||
}
|
||||
fill.RealizedMinor = fill.CashDeltaMinor - cost
|
||||
}
|
||||
err = tx.QueryRow(ctx, `INSERT INTO veyra_trade_fill(id,account_id,plan_id,idempotency_key,input_hash,side,symbol,exchange,name,quantity,price_minor,fee_minor,cash_delta_minor,realized_minor,occurred_at,sellable_at,note) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING posted_sequence,created_at`, fill.ID, fill.AccountID, fill.PlanID, fill.IdempotencyKey, fill.InputHash, fill.Side, fill.Symbol, fill.Exchange, fill.Name, fill.Quantity, fill.PriceMinor, fill.FeeMinor, fill.CashDeltaMinor, fill.RealizedMinor, fill.OccurredAt, fill.SellableAt, fill.Note).Scan(&fill.PostedSequence, &fill.CreatedAt)
|
||||
if err != nil {
|
||||
return fill, mapError(err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_cash_entry(id,account_id,entry_type,amount_minor,fill_id) VALUES($1,$2,$3,$4,$5)`, uuid.NewString(), fill.AccountID, fill.Side, fill.CashDeltaMinor, fill.ID)
|
||||
if err != nil {
|
||||
return fill, mapError(err)
|
||||
}
|
||||
if fill.Side == "BUY" {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_position_lot(id,account_id,buy_fill_id,symbol,exchange,remaining_quantity,remaining_cost_minor,sellable_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, uuid.NewString(), fill.AccountID, fill.ID, fill.Symbol, fill.Exchange, fill.Quantity, -fill.CashDeltaMinor, fill.SellableAt)
|
||||
if err != nil {
|
||||
return fill, mapError(err)
|
||||
}
|
||||
} else {
|
||||
for _, a := range allocations {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_fill_allocation(id,sell_fill_id,lot_id,quantity,cost_minor) VALUES($1,$2,$3,$4,$5)`, a.ID, a.SellFillID, a.LotID, a.Quantity, a.CostMinor)
|
||||
if err != nil {
|
||||
return fill, mapError(err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE veyra_position_lot SET remaining_quantity=remaining_quantity-$2,remaining_cost_minor=remaining_cost_minor-$3 WHERE id=$1`, a.LotID, a.Quantity, a.CostMinor)
|
||||
if err != nil {
|
||||
return fill, mapError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if plan != nil {
|
||||
remaining := plan.Quantity - plan.FilledQuantity - fill.Quantity
|
||||
high, err := model.ParseMoney(plan.CandidateContent.ReferencePlan.EntryHigh)
|
||||
if err != nil {
|
||||
return fill, err
|
||||
}
|
||||
otherReserved := reserved
|
||||
newStatus, reserved := plan.Status, int64(0)
|
||||
if plan.Status == "ACTIVE" || plan.Status == "PART_FILLED" {
|
||||
if remaining == 0 {
|
||||
newStatus = "COMPLETED"
|
||||
} else {
|
||||
newStatus = "PART_FILLED"
|
||||
reserved = high*remaining + plan.FeeBudgetMinor
|
||||
if cash+fill.CashDeltaMinor < reserved+otherReserved {
|
||||
newStatus = "INVALIDATED"
|
||||
reserved = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE veyra_trade_plan SET filled_quantity=filled_quantity+$2,reserved_minor=$3,status=$4,version=version+1,updated_at=now() WHERE id=$1`, plan.ID, fill.Quantity, reserved, newStatus)
|
||||
if err != nil {
|
||||
return fill, mapError(err)
|
||||
}
|
||||
}
|
||||
if err := appendAudit(ctx, tx, user, "FILL_RECORDED", "trade_fill", fill.ID, requestID, map[string]any{"side": fill.Side, "quantity": fill.Quantity, "plan_id": fill.PlanID}); err != nil {
|
||||
return fill, err
|
||||
}
|
||||
if err := appendPositionEvent(ctx, tx, fill.AccountID, fill.ID, "trade_fill", "position.changed"); err != nil {
|
||||
return fill, err
|
||||
}
|
||||
saved, err := scanFill(tx.QueryRow(ctx, `SELECT `+fillColumns+` FROM veyra_trade_fill f WHERE f.id=$1`, fill.ID))
|
||||
if err != nil {
|
||||
return saved, err
|
||||
}
|
||||
return saved, mapError(tx.Commit(ctx))
|
||||
}
|
||||
func (r *TradeFillRepository) List(ctx context.Context, user, account string) ([]model.TradeFill, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if err := lockSimulationAccount(ctx, tx, user, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT `+fillColumns+` FROM veyra_trade_fill f WHERE f.account_id=$1 ORDER BY f.posted_sequence DESC LIMIT 100`, account)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
list := make([]model.TradeFill, 0)
|
||||
for rows.Next() {
|
||||
f, err := scanFill(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, f)
|
||||
}
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
func (r *TradeFillRepository) Reverse(ctx context.Context, user, id, reason, requestID string) (model.TradeReversal, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return model.TradeReversal{}, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
var account string
|
||||
if err := tx.QueryRow(ctx, `SELECT f.account_id FROM veyra_trade_fill f JOIN veyra_account a ON a.id=f.account_id WHERE f.id=$1 AND a.user_id=$2`, id, user).Scan(&account); err != nil {
|
||||
return model.TradeReversal{}, mapError(err)
|
||||
}
|
||||
if err := lockSimulationAccount(ctx, tx, user, account); err != nil {
|
||||
return model.TradeReversal{}, err
|
||||
}
|
||||
var existing model.TradeReversal
|
||||
err = tx.QueryRow(ctx, `SELECT id,fill_id,actor_id,reason,created_at FROM veyra_trade_reversal WHERE fill_id=$1`, id).Scan(&existing.ID, &existing.FillID, &existing.ActorID, &existing.Reason, &existing.CreatedAt)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if err != pgx.ErrNoRows {
|
||||
return existing, mapError(err)
|
||||
}
|
||||
fill, err := scanFill(tx.QueryRow(ctx, `SELECT `+fillColumns+` FROM veyra_trade_fill f WHERE f.id=$1`, id))
|
||||
if err != nil {
|
||||
return existing, err
|
||||
}
|
||||
var later bool
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM veyra_trade_fill f WHERE f.account_id=$1 AND f.posted_sequence>$2 AND NOT EXISTS(SELECT 1 FROM veyra_trade_reversal x WHERE x.fill_id=f.id))`, account, fill.PostedSequence).Scan(&later)
|
||||
if err != nil {
|
||||
return existing, mapError(err)
|
||||
}
|
||||
if later {
|
||||
return existing, apperror.ErrDependentFill
|
||||
}
|
||||
cash, reserved, err := budgetMinor(ctx, tx, account, "")
|
||||
if err != nil {
|
||||
return existing, err
|
||||
}
|
||||
if cash-fill.CashDeltaMinor < reserved {
|
||||
return existing, apperror.ErrInsufficientCash
|
||||
}
|
||||
if fill.Side == "BUY" {
|
||||
tag, err := tx.Exec(ctx, `UPDATE veyra_position_lot SET remaining_quantity=0,remaining_cost_minor=0 WHERE buy_fill_id=$1 AND remaining_quantity=$2 AND remaining_cost_minor=$3`, id, fill.Quantity, -fill.CashDeltaMinor)
|
||||
if err != nil {
|
||||
return existing, mapError(err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return existing, apperror.ErrConflict
|
||||
}
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `UPDATE veyra_position_lot l SET remaining_quantity=l.remaining_quantity+a.quantity,remaining_cost_minor=l.remaining_cost_minor+a.cost_minor FROM veyra_fill_allocation a WHERE a.lot_id=l.id AND a.sell_fill_id=$1`, id)
|
||||
if err != nil {
|
||||
return existing, mapError(err)
|
||||
}
|
||||
}
|
||||
reversal := model.TradeReversal{ID: uuid.NewString(), FillID: id, ActorID: user, Reason: reason}
|
||||
err = tx.QueryRow(ctx, `INSERT INTO veyra_trade_reversal(id,fill_id,actor_id,reason) VALUES($1,$2,$3,$4) RETURNING created_at`, reversal.ID, id, user, reason).Scan(&reversal.CreatedAt)
|
||||
if err != nil {
|
||||
return reversal, mapError(err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO veyra_cash_entry(id,account_id,entry_type,amount_minor,reversal_id) VALUES($1,$2,'REVERSAL',$3,$4)`, uuid.NewString(), account, -fill.CashDeltaMinor, reversal.ID)
|
||||
if err != nil {
|
||||
return reversal, mapError(err)
|
||||
}
|
||||
if fill.PlanID != nil {
|
||||
_, err = tx.Exec(ctx, `UPDATE veyra_trade_plan SET filled_quantity=filled_quantity-$2,status='INVALIDATED',reserved_minor=0,version=version+1,updated_at=now() WHERE id=$1`, *fill.PlanID, fill.Quantity)
|
||||
if err != nil {
|
||||
return reversal, mapError(err)
|
||||
}
|
||||
}
|
||||
if err := appendAudit(ctx, tx, user, "FILL_REVERSED", "trade_fill", id, requestID, map[string]any{"reason": reason, "reversal_id": reversal.ID, "plan_reopened": false}); err != nil {
|
||||
return reversal, err
|
||||
}
|
||||
if err := appendPositionEvent(ctx, tx, account, reversal.ID, "trade_reversal", "position.changed"); err != nil {
|
||||
return reversal, err
|
||||
}
|
||||
return reversal, mapError(tx.Commit(ctx))
|
||||
}
|
||||
func (r *TradeFillRepository) Portfolio(ctx context.Context, user, account string) (model.PortfolioSummary, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return model.PortfolioSummary{}, mapError(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if err := lockSimulationAccount(ctx, tx, user, account); err != nil {
|
||||
return model.PortfolioSummary{}, err
|
||||
}
|
||||
var cash, realized 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 model.PortfolioSummary{}, mapError(err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT COALESCE(sum(f.realized_minor),0)::bigint FROM veyra_trade_fill f WHERE account_id=$1 AND NOT EXISTS(SELECT 1 FROM veyra_trade_reversal x WHERE x.fill_id=f.id)`, account).Scan(&realized); err != nil {
|
||||
return model.PortfolioSummary{}, mapError(err)
|
||||
}
|
||||
portfolio := model.PortfolioSummary{AccountID: account, CashBalance: model.FormatMoney(cash), RealizedPnL: model.FormatMoney(realized), Positions: make([]model.PositionSummary, 0)}
|
||||
var buy, sell, fees int64
|
||||
err = tx.QueryRow(ctx, `SELECT count(*),count(*) FILTER(WHERE f.side='SELL'),count(*) FILTER(WHERE f.side='SELL' AND realized_minor>0),count(*) FILTER(WHERE f.side='SELL' AND realized_minor<0),COALESCE(sum(-cash_delta_minor) FILTER(WHERE f.side='BUY'),0)::bigint,COALESCE(sum(cash_delta_minor) FILTER(WHERE f.side='SELL'),0)::bigint,COALESCE(sum(fee_minor),0)::bigint FROM veyra_trade_fill f WHERE f.account_id=$1 AND NOT EXISTS(SELECT 1 FROM veyra_trade_reversal x WHERE x.fill_id=f.id)`, account).Scan(&portfolio.Review.TradeCount, &portfolio.Review.SellCount, &portfolio.Review.ProfitableSellCount, &portfolio.Review.LosingSellCount, &buy, &sell, &fees)
|
||||
if err != nil {
|
||||
return portfolio, mapError(err)
|
||||
}
|
||||
portfolio.Review.BuyOutflow = model.FormatMoney(buy)
|
||||
portfolio.Review.SellInflow = model.FormatMoney(sell)
|
||||
portfolio.Review.TotalFees = model.FormatMoney(fees)
|
||||
rows, err := tx.Query(ctx, `SELECT l.symbol,l.exchange,min(f.name),sum(l.remaining_quantity)::bigint,COALESCE(sum(l.remaining_quantity) FILTER(WHERE l.sellable_at<=now()),0)::bigint,sum(l.remaining_cost_minor)::bigint FROM veyra_position_lot l JOIN veyra_trade_fill f ON f.id=l.buy_fill_id WHERE l.account_id=$1 AND l.remaining_quantity>0 GROUP BY l.symbol,l.exchange ORDER BY l.exchange,l.symbol`, account)
|
||||
if err != nil {
|
||||
return portfolio, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p model.PositionSummary
|
||||
var qty, sellable, cost int64
|
||||
if err := rows.Scan(&p.Symbol, &p.Exchange, &p.Name, &qty, &sellable, &cost); err != nil {
|
||||
return portfolio, mapError(err)
|
||||
}
|
||||
p.Quantity = strconv.FormatInt(qty, 10)
|
||||
p.SellableQuantity = strconv.FormatInt(sellable, 10)
|
||||
p.CostBasis = model.FormatMoney(cost)
|
||||
portfolio.Positions = append(portfolio.Positions, p)
|
||||
}
|
||||
return portfolio, mapError(rows.Err())
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import sys, json
|
||||
from decimal import Decimal
|
||||
|
||||
def cents(value):
|
||||
n = Decimal(value) * 100
|
||||
if n != n.to_integral_value() or n <= 0:
|
||||
raise ValueError('invalid price')
|
||||
return int(n)
|
||||
|
||||
def money(value):
|
||||
return ('-' if value < 0 else '') + str(abs(value)//100) + '.' + str(abs(value)%100).zfill(2)
|
||||
|
||||
def run(data):
|
||||
p = data['parameters']
|
||||
bars = data['bars']
|
||||
cash = int(Decimal(data['initial_cash'])*100)
|
||||
initial = cash
|
||||
quantity, cost, peak, drawdown, previous = 0, 0, cash, 0, False
|
||||
trades, equity = [], []
|
||||
closes = [cents(b['close']) for b in bars]
|
||||
for i, bar in enumerate(bars):
|
||||
# Only preceding completed bars generate a signal. Execute at next open.
|
||||
if i >= p['long_window']:
|
||||
short = sum(closes[i-p['short_window']:i]) * p['long_window']
|
||||
long = sum(closes[i-p['long_window']:i]) * p['short_window']
|
||||
bullish = short > long
|
||||
open_price = cents(bar['open'])
|
||||
if bullish and not previous and quantity == 0:
|
||||
price = (open_price*(10000+p['slippage_bps'])+9999)//10000
|
||||
unit = p['lot_size']
|
||||
units = cash*10000//(price*unit*(10000+p['fee_bps']))
|
||||
quantity = units*unit
|
||||
gross = quantity*price
|
||||
fee = (gross*p['fee_bps']+9999)//10000
|
||||
if quantity:
|
||||
cash -= gross+fee
|
||||
cost = gross+fee
|
||||
trades.append({'date':bar['date'],'side':'BUY','quantity':str(quantity),'price':money(price),'fee':money(fee),'realized_pnl':None})
|
||||
elif not bullish and previous and quantity:
|
||||
price = max(1,open_price*(10000-p['slippage_bps'])//10000)
|
||||
gross = quantity*price
|
||||
fee = (gross*p['fee_bps']+9999)//10000
|
||||
cash += gross-fee
|
||||
trades.append({'date':bar['date'],'side':'SELL','quantity':str(quantity),'price':money(price),'fee':money(fee),'realized_pnl':money(gross-fee-cost)})
|
||||
quantity, cost = 0, 0
|
||||
previous = bullish
|
||||
value = cash+quantity*closes[i]
|
||||
peak = max(peak,value)
|
||||
drawdown = max(drawdown,(peak-value)*10000//peak)
|
||||
equity.append({'date':bar['date'],'equity':money(value)})
|
||||
final = cash+quantity*closes[-1]
|
||||
return {'engine_version':'sma_cross_v1','final_equity':money(final),'cash':money(cash),'remaining_quantity':str(quantity),'total_return_bps':(final-initial)*10000//initial,'max_drawdown_bps':drawdown,'trades':trades,'equity_curve':equity,'valuation':'Final close valuation; open positions are not liquidated.'}
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(run(json.load(sys.stdin)),separators=(',',':')))
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package research executes the embedded Python research engine with bounded inputs.
|
||||
package research
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed backtest.py
|
||||
var script string
|
||||
|
||||
type Engine struct{ executable string }
|
||||
|
||||
func New(executable string) *Engine { return &Engine{executable} }
|
||||
func (e *Engine) Execute(ctx context.Context, input json.RawMessage) (json.RawMessage, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, e.executable, "-I", "-c", script)
|
||||
// No application credentials or Python startup configuration reach the child.
|
||||
cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "LANG=en_US.UTF-8"}
|
||||
cmd.Stdin = bytes.NewReader(input)
|
||||
var output boundedBuffer
|
||||
cmd.Stdout = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, errors.New("Python research execution failed; check Python installation and input data")
|
||||
}
|
||||
if !json.Valid(output.Bytes()) {
|
||||
return nil, errors.New("invalid research result")
|
||||
}
|
||||
return json.RawMessage(output.Bytes()), nil
|
||||
}
|
||||
|
||||
type boundedBuffer struct{ bytes.Buffer }
|
||||
|
||||
func (b *boundedBuffer) Write(p []byte) (int, error) {
|
||||
if b.Len()+len(p) > 2<<20 {
|
||||
return 0, errors.New("research output exceeds limit")
|
||||
}
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNoLookaheadAndExactCash(t *testing.T) {
|
||||
python, e := exec.LookPath("python3")
|
||||
if e != nil {
|
||||
t.Fatal("Python 3 is required for research tests")
|
||||
}
|
||||
values := []string{"10.00", "9.00", "8.00", "20.00", "21.00", "7.00", "6.00", "5.00"}
|
||||
bars := make([]model.HistoricalBar, len(values))
|
||||
for i, p := range values {
|
||||
bars[i] = model.HistoricalBar{Date: []string{"2025-01-01", "2025-01-02", "2025-01-03", "2025-01-04", "2025-01-05", "2025-01-06", "2025-01-07", "2025-01-08"}[i], Open: p, High: p, Low: p, Close: p}
|
||||
}
|
||||
input := model.ResearchInput{BacktestInput: model.BacktestInput{InitialCash: "10000.00", Bars: bars}, Parameters: model.StrategyParameters{ShortWindow: 2, LongWindow: 3, FeeBPS: 10, SlippageBPS: 0, LotSize: 100}}
|
||||
data, _ := json.Marshal(input)
|
||||
result, e := New(python).Execute(context.Background(), data)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var got struct {
|
||||
FinalEquity string `json:"final_equity"`
|
||||
Trades []struct{ Date, Side, Quantity, Price, Fee string }
|
||||
}
|
||||
if e := json.Unmarshal(result, &got); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(got.Trades) != 2 || got.Trades[0].Date != "2025-01-05" || got.Trades[0].Side != "BUY" || got.Trades[0].Quantity != "400" || got.Trades[0].Fee != "8.40" || got.Trades[1].Date != "2025-01-07" || got.FinalEquity != "3989.20" {
|
||||
t.Fatalf("unexpected result: %s", result)
|
||||
}
|
||||
// Changing the current close cannot change a trade at that day's open.
|
||||
input.Bars[4].Close = "1.00"
|
||||
data, _ = json.Marshal(input)
|
||||
result, e = New(python).Execute(context.Background(), data)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var changed struct {
|
||||
Trades []struct{ Date, Side, Quantity, Price, Fee string }
|
||||
}
|
||||
if json.Unmarshal(result, &changed) != nil || len(changed.Trades) == 0 || changed.Trades[0] != got.Trades[0] {
|
||||
t.Fatal("current close leaked into open execution")
|
||||
}
|
||||
}
|
||||
func TestMissingInterpreter(t *testing.T) {
|
||||
if _, e := New("/not/an/interpreter").Execute(context.Background(), json.RawMessage(`{}`)); e == nil {
|
||||
t.Fatal("missing Python must fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package secret encrypts provider credentials at rest using an environment-owned key.
|
||||
package secret
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
)
|
||||
|
||||
type Vault struct{ aead cipher.AEAD }
|
||||
|
||||
func NewVault(encodedKey string) (*Vault, error) {
|
||||
if encodedKey == "" {
|
||||
return &Vault{}, nil
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, fmt.Errorf("SECRET_ENCRYPTION_KEY must be base64 of 32 bytes")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize secret cipher")
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize secret encryption")
|
||||
}
|
||||
return &Vault{aead: aead}, nil
|
||||
}
|
||||
func (v *Vault) Encrypt(value, binding string) ([]byte, error) {
|
||||
if v == nil || v.aead == nil {
|
||||
return nil, apperror.ErrUnavailable
|
||||
}
|
||||
nonce := make([]byte, v.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, apperror.ErrUnavailable
|
||||
}
|
||||
return v.aead.Seal(nonce, nonce, []byte(value), []byte(binding)), nil
|
||||
}
|
||||
func (v *Vault) Decrypt(encrypted []byte, binding string) (string, error) {
|
||||
if len(encrypted) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if v == nil || v.aead == nil || len(encrypted) < v.aead.NonceSize() {
|
||||
return "", apperror.ErrUnavailable
|
||||
}
|
||||
value, err := v.aead.Open(nil, encrypted[:v.aead.NonceSize()], encrypted[v.aead.NonceSize():], []byte(binding))
|
||||
if err != nil {
|
||||
return "", apperror.ErrUnavailable
|
||||
}
|
||||
return string(value), nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVaultAuthenticatedEncryption(t *testing.T) {
|
||||
v, err := NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := v.Encrypt("private-api-key", "model_primary")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := v.Encrypt("private-api-key", "model_primary")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Equal(first, second) || bytes.Contains(first, []byte("private-api-key")) {
|
||||
t.Fatal("encryption lacks nonce or exposes secret")
|
||||
}
|
||||
value, err := v.Decrypt(first, "model_primary")
|
||||
if err != nil || value != "private-api-key" {
|
||||
t.Fatal("decrypt failed")
|
||||
}
|
||||
if _, err := v.Decrypt(first, "model_backup"); err == nil {
|
||||
t.Fatal("cross-configuration ciphertext accepted")
|
||||
}
|
||||
first[len(first)-1] ^= 1
|
||||
if _, err := v.Decrypt(first, "model_primary"); err == nil {
|
||||
t.Fatal("tampered ciphertext accepted")
|
||||
}
|
||||
empty, _ := NewVault("")
|
||||
if _, err := empty.Encrypt("secret", "model_primary"); err == nil {
|
||||
t.Fatal("missing master key accepted")
|
||||
}
|
||||
if _, err := NewVault("invalid"); err == nil {
|
||||
t.Fatal("invalid master key accepted")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user