Compare commits
12
Commits
bf643b0363
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b811dec1 | ||
|
|
2acfc64d58 | ||
|
|
d65b6bc84f | ||
|
|
c8c61d29bf | ||
|
|
5003a44d2b | ||
|
|
9d56f6169a | ||
|
|
7ad49c4064 | ||
|
|
1496d77bcc | ||
|
|
393f83bb9a | ||
|
|
d39fb0e0d2 | ||
|
|
b47889c5f3 | ||
|
|
c2bf4b99b8 |
@@ -17,9 +17,21 @@ jobs:
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fLsS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
archive_path="$(mktemp)"
|
||||
trap 'rm -f "$archive_path"' EXIT
|
||||
archive_ready=false
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if curl -fLsS --connect-timeout 10 --max-time 180 --retry 2 --retry-all-errors \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"https://git.sundynix.cn/${REPOSITORY}/archive/${COMMIT_SHA}.tar.gz" \
|
||||
| tar -xz --strip-components=1
|
||||
-o "$archive_path" && tar -tzf "$archive_path" >/dev/null 2>&1; then
|
||||
archive_ready=true
|
||||
break
|
||||
fi
|
||||
sleep "$attempt"
|
||||
done
|
||||
test "$archive_ready" = true
|
||||
tar -xzf "$archive_path" --strip-components=1
|
||||
- name: Install SSH tools
|
||||
run: |
|
||||
set -eu
|
||||
@@ -48,9 +60,21 @@ jobs:
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fLsS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
archive_path="$(mktemp)"
|
||||
trap 'rm -f "$archive_path"' EXIT
|
||||
archive_ready=false
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if curl -fLsS --connect-timeout 10 --max-time 180 --retry 2 --retry-all-errors \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"https://git.sundynix.cn/${REPOSITORY}/archive/${COMMIT_SHA}.tar.gz" \
|
||||
| tar -xz --strip-components=1
|
||||
-o "$archive_path" && tar -tzf "$archive_path" >/dev/null 2>&1; then
|
||||
archive_ready=true
|
||||
break
|
||||
fi
|
||||
sleep "$attempt"
|
||||
done
|
||||
test "$archive_ready" = true
|
||||
tar -xzf "$archive_path" --strip-components=1
|
||||
- name: Install SSH tools
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
@@ -2,13 +2,32 @@
|
||||
|
||||
更新日期:2026-10-09。
|
||||
|
||||
## AI 接入与本轮部署(2026-10-09,进行中)
|
||||
## AI 接入与本轮部署(2026-10-09)
|
||||
|
||||
- 用户后来指定 GPT-6.1-Astra;实际供应商目录没有此 ID,请求 404。用户确认改用 gpt-6.1-sol,模型目录匹配且约 6.8 秒真实结构化连接测试通过;主配置已保存该模型,密钥保留。
|
||||
- 用户明确要求 AI 分析/审核通过后自动发布、不逐条人工操作,并授权通过 CI/CD 部署到现有服务器。新增十五版迁移:审核策略配置与候选分析任务,所有表使用 veyra_、字符串 UUID。
|
||||
- 接入 Responses / Chat Completions 结构化调用,密钥从现有 Vault 解密,禁止重定向携带密钥;主分析与独立复核严格验证证券、Schema、证据索引与文本边界。不改写价格、不生成用户计划或成交。
|
||||
- 后台增加 AI 审核和自动发布开关、任务状态、处理入口、分析与复核结果;审核模型可安全复用主配置。Worker 每 30 秒检查,五分钟租约、版本去重、失败最多三次并间隔五分钟;提交时重新核对配置、授权身份、策略与候选,结果和发布/审计/Outbox 同事务。
|
||||
- App 详情增加真实 AI 分析、复核与资料缺口;统一 Screen 隐藏纵横滚动条,保留滑动。完整 Go race、类型检查、管理端构建与 App 双端 Hermes 导出通过。实际服务器调用与最终部署状态稍后补记。
|
||||
- iPhone 包新增 Expo 云端未签名设备 IPA 构建;只上传隔离 App 源码,不上传后端私有环境。首轮自定义步骤名称错误已修正为显式 pod install;已提交构建 e36bba44-4949-4069-a2fb-e1f491dac7f1,普通 Apple ID 安装仍需个人重签,尚未声称安装包完成或可直接安装。
|
||||
- App 详情增加真实 AI 分析、复核与资料缺口;统一 Screen 隐藏纵横滚动条,保留滑动。完整 Go race、类型检查、管理端构建与 App 双端 Hermes 导出通过。已用现有 Vault 密钥实际调用 gpt-5.5:约 3.4 秒返回有效结构化 REJECT(明确缺少研究数据的连接测试),接口可用且没有发布测试候选。PR #3 合并 bf643b0,run 40 因源码下载截断失败,未部署;下载重试与 tar 完整性校验经 PR #4 合并 b47889c,run 41 的 verify/deploy 均成功,容器 healthy。
|
||||
- iPhone 包新增 Expo 云端未签名设备 IPA 构建;只上传隔离 App 源码,不上传后端私有环境。首轮自定义步骤名称错误已修正为显式 pod install;已提交构建 e36bba44-4949-4069-a2fb-e1f491dac7f1,构建已 FINISHED;设备 IPA 已下载到 Downloads/Veyra-unsigned.ipa(10,953,223 字节),zip 完整性、cn.sundynix.veyra、iPhoneOS/arm64、内嵌 Hermes 和公开 HTTPS 地址验证通过,无签名。SHA-256 19b6650dd73712fcf612f9b84b3cd7efdc8e5470d99b3f0d17bbaabe5972a9ac。IPA 与校验文件已上传 Gitea 预发布 v0.1.0-preview.20261009;普通 Apple ID 仍需个人重签,实际安装运行待用户完成。
|
||||
|
||||
- 实际主分析与复核均已启用 gpt-6.1-sol,现有加密密钥保留;AI 审核策略版本 2 已启用自动发布。核对实际审计:两条已有候选在 22:11 已经人工发布,一个计划在 22:17 已确认,均早于本轮 AI 启用;App 当前可见两条候选、一个计划、原有模拟账户现金 1000.00。不能把既有人工发布称作 AI 结果;暂无待审核候选,AI 任务列表为空,后续新候选自动处理。没有创建或改写用户资金、计划、成交。
|
||||
- 部署后的配置校验发现旧数据库 timeout_seconds 上限仍为 30,已新增十六版迁移匹配调用端 120 秒上限,并使用实际数据库隔离 schema 测试 120 秒配置持久化;Go race 与 go vet 通过,已包含 PR #7 的 run 44 部署,迁移 16 生效;主分析版本 4、审核模型版本 2 均启用 gpt-6.1-sol,超时 120 秒已成功保存。
|
||||
|
||||
## 股票名称搜索修复(2026-10-09)
|
||||
|
||||
- 实际 HTTPS 查询复现:名称“诺普信”无结果,但代码 002215 和名称“诺 普 信”均返回该深交所股票。数据源在名称中填充了空格,原 SQL LIKE 连续匹配漏掉无空格查询,证券并未漏同步。
|
||||
- 查询统一忽略名称与输入中的空格、制表符和全角空格,保持原始源名称;转义 LIKE 通配符为字面匹配,避免输入 % 或 _ 返回全部股票。管理端无搜索结果文案与无同步数据区分。真实 PostgreSQL 隔离 schema 的 race 回归覆盖名称/代码/空格和通配符,go vet、管理端类型检查/build/embed 通过;PR #6 合并后由包含后续代码展示的 run 44 成功部署,实际 HTTPS 与浏览器查询“诺普信”“诺 普 信”“002215”均匹配同一证券;行情及候选标题代码已线上确认。较早的 run 42/43 verify 成功,因已被新版包含而取消待部署任务,未删除记录。
|
||||
|
||||
- 按用户补充要求,管理端行情列表/详情、候选队列、审核详情/确认、总览观察与信号卡将代码与名称并列展示,代码使用等宽字形;选股、回测详情、策略证券列表以及 App 候选/计划/持仓/成交原已有代码,继续保留。另补齐回测任务列表与 AI 任务执行中/失败时的股票身份:代码从不可覆盖输入/候选快照读取,不等待模型输出。管理端统一 StockIdentity 组件,类型检查/build/embed 通过。
|
||||
- 完整 Go race 与 go vet、管理端类型检查/build/embed 通过;新增列表集成检查保留证券代码且不返回完整回测输入。真实 gpt-6.1-sol 使用中洲控股的现有日线完成分析与独立复核(约 40 秒合计),两次结构化输出及证据索引校验通过;仅只读连接验证,没有写入 AI 发布事实。
|
||||
- 用户最新指示先不处理 App 安装,保留已有 IPA 产物;当前优先验证名称搜索、股票代码展示与模型运行配置。
|
||||
|
||||
## 日线超时修复(2026-10-09)
|
||||
|
||||
- 用户截图中的诺普信日线错误已核对服务日志:两次 503 耗时 4002/4003 ms,行情源配置超时为 4 秒;同证券随后真实请求 200、59 根日线,证券与数据库正常。日线外部请求原受全局 5 秒及 HTTP 写响应 10 秒限制。
|
||||
- 日线和自动日线回测入口处理期限扩为 35 秒、服务写响应期限 40 秒,其余请求仍为 5 秒;日线失败且请求未取消时最多重试一次。管理端增加独立重试按钮,错误明确为行情源未返回,不再错误暗示数据库迁移或显示零交易日。取消与两次失败边界测试、handler/service 测试、go vet、管理端类型检查/build/embed 通过,待 CI 部署。
|
||||
- 用户要求三分钟内结束本轮并准备休息:优先提交已验证修复并触发 CI,不声称尚未完成的部署已经上线;后续以实际 Actions 与服务结果为准。App 安装继续暂缓。
|
||||
|
||||
## App Expo Go 真机入口(2026-10-09)
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Keep the persisted limit aligned with the model client's configurable deadline.
|
||||
ALTER TABLE veyra_provider_config
|
||||
DROP CONSTRAINT veyra_provider_config_timeout_seconds_check;
|
||||
ALTER TABLE veyra_provider_config
|
||||
ADD CONSTRAINT veyra_provider_config_timeout_seconds_check
|
||||
CHECK (timeout_seconds BETWEEN 1 AND 120);
|
||||
@@ -19,7 +19,7 @@ func NewRouter(health *service.HealthService, logger *zap.Logger, identity Ident
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout(c.Request))
|
||||
defer cancel()
|
||||
if identity.PublicOrigin != "" {
|
||||
ctx = context.WithValue(ctx, publicOriginContextKey{}, identity.PublicOrigin)
|
||||
@@ -87,3 +87,10 @@ func NewRouter(health *service.HealthService, logger *zap.Logger, identity Ident
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func requestTimeout(r *http.Request) time.Duration {
|
||||
if r.Method == http.MethodGet && (r.URL.Path == "/api/admin/v1/market/history" || r.URL.Path == "/api/v1/market/history") || r.Method == http.MethodPost && r.URL.Path == "/api/admin/v1/backtests/from-market" {
|
||||
return 35 * time.Second
|
||||
}
|
||||
return 5 * time.Second
|
||||
}
|
||||
|
||||
@@ -129,6 +129,11 @@ func testCandidateAI(t *testing.T, ctx context.Context, pool *pgxpool.Pool, admi
|
||||
if err != nil || len(results) != 4 {
|
||||
t.Fatal("durable results missing", err, len(results))
|
||||
}
|
||||
for _, result := range results {
|
||||
if result.Name != "Fixture" || result.Symbol != "000042" || result.Exchange != "SZSE" {
|
||||
t.Fatal("analysis task lost candidate identity")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type rejectModel struct{ calls int }
|
||||
|
||||
@@ -43,11 +43,14 @@ func testProviderLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Pool
|
||||
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}
|
||||
input = service.ProviderInput{Name: "configured vendor", BaseURL: "https://provider.example/v1", ModelName: "configured-model", ModelCatalogURL: "https://provider.example/v1/models", TimeoutSeconds: 120, Enabled: true, APIKey: &token, ExpectedVersion: config.Version}
|
||||
config, err = svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.TimeoutSeconds != 120 {
|
||||
t.Fatal("model deadline was not persisted")
|
||||
}
|
||||
data, _ := json.Marshal(config)
|
||||
if bytes.Contains(data, []byte(token)) || bytes.Contains(data, []byte("encrypted_api_key")) {
|
||||
t.Fatal("API exposes credential")
|
||||
|
||||
@@ -47,6 +47,22 @@ func testScreeningLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Poo
|
||||
if err := repository.NewMarketRepository(pool).Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
searchRepo := repository.NewMarketRepository(pool)
|
||||
if err := searchRepo.Save(ctx, provider, []model.Instrument{{Symbol: "002215", Exchange: "SZSE", Name: "诺 普 信", PriceCents: 1000, ObservedAt: observed}}, time.Now().UTC(), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, query := range []string{"诺普信", "诺 普 信", " 诺\t普 信 ", "002215", "002 215"} {
|
||||
stocks, err := searchRepo.List(ctx, query)
|
||||
if err != nil || len(stocks) != 1 || stocks[0].Symbol != "002215" || stocks[0].Name != "诺 普 信" {
|
||||
t.Fatalf("padded security search %q: %v, %v", query, stocks, err)
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"not-a-security", "%", "_"} {
|
||||
stocks, err := searchRepo.List(ctx, query)
|
||||
if err != nil || len(stocks) != 0 {
|
||||
t.Fatalf("literal security search %q matched: %v, %v", query, stocks, err)
|
||||
}
|
||||
}
|
||||
repo := repository.NewScreeningRepository(pool)
|
||||
targets, err := repo.Targets(ctx, provider)
|
||||
if err != nil || len(targets) != 1 {
|
||||
|
||||
@@ -95,6 +95,10 @@ func testStrategyBacktest(t *testing.T, ctx context.Context, pool *pgxpool.Pool,
|
||||
if e != nil || done.Status != "COMPLETED" || done.FinishedAt == nil || len(done.Result) == 0 {
|
||||
t.Fatalf("completed job: %v", e)
|
||||
}
|
||||
listed, e := repo.List(ctx)
|
||||
if e != nil || len(listed) != 1 || listed[0].Symbol != bi.Symbol || listed[0].Input != nil || listed[0].Result != nil {
|
||||
t.Fatal("backtest list must retain the symbol without returning large inputs")
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type ResearchInput struct {
|
||||
Parameters StrategyParameters `json:"parameters"`
|
||||
}
|
||||
type BacktestRun struct {
|
||||
Symbol string `json:"symbol"`
|
||||
ID string `db:"id" json:"id"`
|
||||
StrategyVersionID string `db:"strategy_version_id" json:"strategy_version_id"`
|
||||
Status string `db:"status" json:"status"`
|
||||
|
||||
@@ -23,6 +23,9 @@ type AIConclusion struct {
|
||||
MissingInformation []string `json:"missing_information"`
|
||||
}
|
||||
type CandidateAnalysis struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Symbol string `json:"symbol,omitempty"`
|
||||
Exchange string `json:"exchange,omitempty"`
|
||||
ID string `db:"id" json:"id"`
|
||||
CandidateRevisionID string `db:"candidate_revision_id" json:"candidate_revision_id"`
|
||||
InputVersion int64 `db:"input_version" json:"input_version"`
|
||||
|
||||
@@ -21,6 +21,13 @@ const backtestColumns = `id,strategy_version_id,status,input,input_hash,result,f
|
||||
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)
|
||||
if err == nil {
|
||||
var identity struct {
|
||||
Symbol string `json:"symbol"`
|
||||
}
|
||||
err = json.Unmarshal(v.Input, &identity)
|
||||
v.Symbol = identity.Symbol
|
||||
}
|
||||
return v, mapError(err)
|
||||
}
|
||||
func (r *BacktestRepository) Detail(ctx context.Context, id string) (model.BacktestRun, error) {
|
||||
|
||||
@@ -183,7 +183,7 @@ func (r *CandidateAnalysisRepository) Complete(ctx context.Context, p model.AIRe
|
||||
return mapError(tx.Commit(ctx))
|
||||
}
|
||||
func (r *CandidateAnalysisRepository) List(ctx context.Context) ([]model.CandidateAnalysis, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT id,candidate_revision_id,input_version,policy_version,status,attempts,primary_model,review_model,primary_version,review_version,analysis,review,message,created_at,updated_at FROM veyra_candidate_analysis ORDER BY updated_at DESC LIMIT 100`)
|
||||
rows, err := r.pool.Query(ctx, `SELECT a.id,a.candidate_revision_id,a.input_version,a.policy_version,a.status,a.attempts,a.primary_model,a.review_model,a.primary_version,a.review_version,a.analysis,a.review,a.message,a.created_at,a.updated_at,r.snapshot->>'name',r.snapshot->>'symbol',r.snapshot->>'exchange' FROM veyra_candidate_analysis a JOIN veyra_candidate_revision r ON r.id=a.candidate_revision_id ORDER BY a.updated_at DESC LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func (r *CandidateAnalysisRepository) List(ctx context.Context) ([]model.Candida
|
||||
for rows.Next() {
|
||||
var v model.CandidateAnalysis
|
||||
var a, b []byte
|
||||
if err = rows.Scan(&v.ID, &v.CandidateRevisionID, &v.InputVersion, &v.PolicyVersion, &v.Status, &v.Attempts, &v.PrimaryModel, &v.ReviewModel, &v.PrimaryVersion, &v.ReviewVersion, &a, &b, &v.Message, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||
if err = rows.Scan(&v.ID, &v.CandidateRevisionID, &v.InputVersion, &v.PolicyVersion, &v.Status, &v.Attempts, &v.PrimaryModel, &v.ReviewModel, &v.PrimaryVersion, &v.ReviewVersion, &a, &b, &v.Message, &v.CreatedAt, &v.UpdatedAt, &v.Name, &v.Symbol, &v.Exchange); err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
if len(a) > 0 {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -14,7 +15,10 @@ 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+"%")
|
||||
// Vendors pad some short names with whitespace (for example "诺 普 信").
|
||||
// Normalize only for matching; preserve the supplied name in stored data.
|
||||
pattern := "%" + strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(query) + "%"
|
||||
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 regexp_replace($1,'[[:space:] ]+','','g') OR regexp_replace(name,'[[:space:] ]+','','g') LIKE regexp_replace($1,'[[:space:] ]+','','g') ORDER BY (price_cents>0) DESC,symbol LIMIT 100`, pattern)
|
||||
if e != nil {
|
||||
return nil, mapError(e)
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@ func (s *MarketService) History(ctx context.Context, u model.User, symbol, excha
|
||||
return model.MarketHistory{}, e
|
||||
}
|
||||
result, e := s.adapter.History(ctx, c, symbol, exchange, start, end)
|
||||
if e != nil && ctx.Err() == nil {
|
||||
result, e = s.adapter.History(ctx, c, symbol, exchange, start, end)
|
||||
}
|
||||
if e != nil {
|
||||
return result, apperror.ErrUnavailable
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type historyProvider struct{ ProviderRepository }
|
||||
|
||||
func (historyProvider) Get(context.Context, string) (model.ProviderConfig, error) {
|
||||
return model.ProviderConfig{Enabled: true, MarketAdapter: "sina_public"}, nil
|
||||
}
|
||||
|
||||
type historyRepo struct{ MarketRepository }
|
||||
type retryHistory struct {
|
||||
MarketAdapter
|
||||
calls int
|
||||
cancel context.CancelFunc
|
||||
alwaysFail bool
|
||||
}
|
||||
|
||||
func (a *retryHistory) History(ctx context.Context, _ model.ProviderConfig, symbol, exchange, start, end string) (model.MarketHistory, error) {
|
||||
a.calls++
|
||||
if a.cancel != nil {
|
||||
a.cancel()
|
||||
}
|
||||
if a.calls == 1 || a.alwaysFail {
|
||||
return model.MarketHistory{}, errors.New("upstream failure")
|
||||
}
|
||||
return model.MarketHistory{Symbol: symbol, Exchange: exchange, Bars: []model.HistoricalBar{{Date: start, Close: "9.41"}}}, nil
|
||||
}
|
||||
func TestHistoryRetriesOnceAndHonorsCancellation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cancel, fail bool
|
||||
calls int
|
||||
}{{"recovers", false, false, 2}, {"bounded failure", false, true, 2}, {"cancelled", true, false, 1}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
adapter := &retryHistory{alwaysFail: tc.fail}
|
||||
if tc.cancel {
|
||||
adapter.cancel = cancel
|
||||
}
|
||||
s := NewMarketService(historyRepo{}, historyProvider{}, adapter, true)
|
||||
result, err := s.History(ctx, model.User{Role: "ADMIN", Status: "ACTIVE"}, "002215", "SZSE", "2026-07-01", "2026-07-02")
|
||||
if adapter.calls != tc.calls {
|
||||
t.Fatalf("calls: %d", adapter.calls)
|
||||
}
|
||||
if tc.fail || tc.cancel {
|
||||
if !errors.Is(err, apperror.ErrUnavailable) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err != nil || result.Symbol != "002215" || len(result.Bars) != 1 {
|
||||
t.Fatal("retry did not preserve real result", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func serve(ctx context.Context, c config.Config, logger *zap.Logger) error {
|
||||
<-analysisDone
|
||||
}()
|
||||
identityOptions := handler.IdentityOptions{Analyses: analyses, Screenings: screenings, Market: marketService, Backtests: backtests, Strategies: service.NewStrategyService(strategyRepo, development), Notifications: notifications, Auth: auth, Fills: service.NewTradeFillService(fillRepo, development), Plans: service.NewTradePlanService(planRepo, development), Candidates: service.NewCandidateService(candidateRepo, development), Providers: service.NewProviderService(providerRepo, vault), Accounts: service.NewAccountService(accountRepo), Limiter: ratelimit.NewRedis(redisClient), DevelopmentLogin: development, SecureCookie: !development || strings.HasPrefix(c.PublicOrigin, "https://"), PublicOrigin: c.PublicOrigin}
|
||||
server := &http.Server{Addr: c.HTTPAddr, Handler: handler.NewRouter(health, logger, identityOptions), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
|
||||
server := &http.Server{Addr: c.HTTPAddr, Handler: handler.NewRouter(health, logger, identityOptions), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 40 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- server.ListenAndServe() }()
|
||||
logger.Info("API starting", zap.String("address", c.HTTPAddr), zap.String("environment", c.Environment))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {StockIdentity} from "./StockIdentity"
|
||||
import {useEffect,useState} from "react"
|
||||
import {request,ApiError} from "@/lib/auth"
|
||||
import {Button} from "@/components/ui/button"
|
||||
@@ -5,7 +6,7 @@ import {Badge} from "@/components/ui/badge"
|
||||
import {Card,CardHeader,CardTitle,CardContent,CardDescription} from "@/components/ui/card"
|
||||
type Policy={enabled:boolean;auto_publish:boolean;version:number}
|
||||
type Conclusion={symbol:string;decision:string;summary:string;risks:string;missing_information:string[]}
|
||||
type Analysis={id:string;status:string;attempts:number;message:string;primary_model:string;review_model:string;analysis?:Conclusion;review?:Conclusion;updated_at:string}
|
||||
type Analysis={id:string;name:string;symbol:string;exchange:string;status:string;attempts:number;message:string;primary_model:string;review_model:string;analysis?:Conclusion;review?:Conclusion;updated_at:string}
|
||||
const states:Record<string,string>={RUNNING:"分析中",FAILED:"调用失败",APPROVED:"已通过 · 等待发布",REJECTED:"AI 未通过",PUBLISHED:"已自动发布",STALE:"版本或规则已失效"}
|
||||
export function AIReviewPanel({onSessionExpired}:{onSessionExpired:()=>void}){
|
||||
const [policy,setPolicy]=useState<Policy|null>(null),[results,setResults]=useState<Analysis[]>([]),[enabled,setEnabled]=useState(false),[auto,setAuto]=useState(false),[error,setError]=useState(""),[busy,setBusy]=useState(false),[notice,setNotice]=useState("")
|
||||
@@ -13,5 +14,5 @@ export function AIReviewPanel({onSessionExpired}:{onSessionExpired:()=>void}){
|
||||
useEffect(()=>{const c=new AbortController();request<Policy>("/api/admin/v1/ai-review-policy",{signal:c.signal}).then(p=>{setPolicy(p);setEnabled(p.enabled);setAuto(p.auto_publish)}).catch(e=>{if(!c.signal.aborted)failure(e)});const load=()=>request<Analysis[]>("/api/admin/v1/candidate-analyses",{signal:c.signal}).then(setResults).catch(e=>{if(!c.signal.aborted)failure(e)});void load();const timer=setInterval(()=>void load(),5000);return()=>{c.abort();clearInterval(timer)}},[onSessionExpired])
|
||||
async function save(){if(!policy)return;setBusy(true);setError("");try{const p=await request<Policy>("/api/admin/v1/ai-review-policy",{method:"PUT",body:JSON.stringify({enabled,auto_publish:auto,expected_version:policy.version})});setPolicy(p);setNotice("审核策略已保存,后台会处理待审核候选。")}catch(e){failure(e)}finally{setBusy(false)}}
|
||||
async function run(){setBusy(true);setError("");try{await request("/api/admin/v1/candidate-analyses/run",{method:"POST"});setNotice("已通知后台处理;结果自动刷新。")}catch(e){failure(e)}finally{setBusy(false)}}
|
||||
return <Card><CardHeader><CardTitle>AI 候选分析与自动审核</CardTitle><CardDescription>策略筛选 → 主模型分析 → 审核模型复核 → 服务端规则校验 → App 发布。仅限模拟研究;不会生成计划或成交。</CardDescription></CardHeader><CardContent className="space-y-5"><div className="flex flex-wrap gap-5"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} disabled={busy||!policy} onChange={e=>setEnabled(e.target.checked)}/>启用 AI 分析与审核</label><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={auto} disabled={busy||!policy} onChange={e=>setAuto(e.target.checked)}/>审核与规则通过后自动发布</label></div><p className="text-sm text-muted-foreground">主模型和审核模型必须配置并启用。关闭自动发布时保留 AI 意见供人工复核;失败最多重试三次,候选过期不发布。</p><div className="flex gap-3"><Button disabled={busy||!policy} onClick={()=>void save()}>保存审核策略</Button><Button variant="outline" disabled={busy||!policy?.enabled} onClick={()=>void run()}>立即处理待审核候选</Button></div>{notice&&<p role="status" className="text-sm">{notice}</p>}{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}<div className="space-y-3">{results.length===0&&<p className="text-sm text-muted-foreground">暂无 AI 任务。启用后自动处理有效待审核候选。</p>}{results.map(a=><div key={a.id} className="rounded-xl border p-4 space-y-2"><div className="flex justify-between gap-3"><strong>{a.analysis?.symbol??"候选分析任务"}</strong><Badge variant="outline">{states[a.status]??a.status}</Badge></div><p className="text-sm">{a.message||"正在调用模型…"}</p>{a.analysis&&<><p className="text-sm leading-6">分析:{a.analysis.summary}</p><p className="text-sm text-muted-foreground">风险:{a.analysis.risks}</p></>}{a.review&&<p className="text-sm leading-6">复核:{a.review.summary}</p>}<p className="text-xs text-muted-foreground">{a.primary_model||"待调用"} / {a.review_model||"待调用"} · 尝试 {a.attempts} · {new Date(a.updated_at).toLocaleString("zh-CN")}</p></div>)}</div></CardContent></Card>
|
||||
return <Card><CardHeader><CardTitle>AI 候选分析与自动审核</CardTitle><CardDescription>策略筛选 → 主模型分析 → 审核模型复核 → 服务端规则校验 → App 发布。仅限模拟研究;不会生成计划或成交。</CardDescription></CardHeader><CardContent className="space-y-5"><div className="flex flex-wrap gap-5"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} disabled={busy||!policy} onChange={e=>setEnabled(e.target.checked)}/>启用 AI 分析与审核</label><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={auto} disabled={busy||!policy} onChange={e=>setAuto(e.target.checked)}/>审核与规则通过后自动发布</label></div><p className="text-sm text-muted-foreground">主模型和审核模型必须配置并启用。关闭自动发布时保留 AI 意见供人工复核;失败最多重试三次,候选过期不发布。</p><div className="flex gap-3"><Button disabled={busy||!policy} onClick={()=>void save()}>保存审核策略</Button><Button variant="outline" disabled={busy||!policy?.enabled} onClick={()=>void run()}>立即处理待审核候选</Button></div>{notice&&<p role="status" className="text-sm">{notice}</p>}{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}<div className="space-y-3">{results.length===0&&<p className="text-sm text-muted-foreground">暂无 AI 任务。启用后自动处理有效待审核候选。</p>}{results.map(a=><div key={a.id} className="rounded-xl border p-4 space-y-2"><div className="flex justify-between gap-3"><strong><StockIdentity name={a.name} symbol={a.symbol}/></strong><Badge variant="outline">{states[a.status]??a.status}</Badge></div><p className="text-sm">{a.message||"正在调用模型…"}</p>{a.analysis&&<><p className="text-sm leading-6">分析:{a.analysis.summary}</p><p className="text-sm text-muted-foreground">风险:{a.analysis.risks}</p></>}{a.review&&<p className="text-sm leading-6">复核:{a.review.summary}</p>}<p className="text-xs text-muted-foreground">{a.primary_model||"待调用"} / {a.review_model||"待调用"} · 尝试 {a.attempts} · {new Date(a.updated_at).toLocaleString("zh-CN")}</p></div>)}</div></CardContent></Card>
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,4 @@
|
||||
import {StockIdentity} from "./StockIdentity"
|
||||
import {useEffect,useRef,useState,type FormEvent} from "react"
|
||||
import {ScreeningRunControl} from "./ScreeningRunControl"
|
||||
import {StockHistoryChart} from "./StockHistoryChart"
|
||||
@@ -35,10 +36,10 @@ export function CandidatesPanel({onSessionExpired,initialCandidateID}:{onSession
|
||||
{([["观察逻辑","thesis"],["反证与风险","counter_evidence"],["失效条件","invalidation"]] as const).map(([label,key])=><label key={key} className="block space-y-2 text-sm"><span>{label}</span><textarea className={control} rows={3} required maxLength={key==="thesis"?2000:1000} value={content[key]} onChange={e=>set(key,e.target.value)}/></label>)}
|
||||
<div className="space-y-3"><h3 className="text-sm font-medium">证据来源</h3>{content.evidence.map((evidence,i)=><div key={i} className="grid gap-3 rounded-lg border p-4 sm:grid-cols-2">{([["来源 HTTPS 链接","source_url"],["页码 / 段落 / 标题","locator"],["证据摘要","summary"]] as const).map(([label,key])=><label className="space-y-2 text-sm" key={key}><span>{label}</span><Input required type={key==="source_url"?"url":"text"} value={evidence[key]} onChange={e=>setContent(c=>({...c,evidence:c.evidence.map((item,index)=>index===i?{...item,[key]:e.target.value}:item)}))}/></label>)}{content.evidence.length>1&&<Button type="button" variant="ghost" onClick={()=>setContent(c=>({...c,evidence:c.evidence.filter((_,index)=>index!==i)}))}>移除此证据</Button>}</div>)}<Button type="button" variant="outline" disabled={content.evidence.length>=8} onClick={()=>setContent(c=>({...c,evidence:[...c.evidence,{source_url:"",locator:"",summary:""}]}))}>添加证据</Button></div>
|
||||
<div className="grid gap-4 sm:grid-cols-4">{([["参考区间下限","entry_low"],["参考区间上限","entry_high"],["风险价格","stop_loss"],["目标观察价格","target_price"]] as const).map(([label,key])=><label className="space-y-2 text-sm" key={key}><span>{label}</span><Input required inputMode="decimal" value={content.reference_plan[key]} onChange={e=>setContent(c=>({...c,reference_plan:{...c.reference_plan,[key]:e.target.value}}))}/></label>)}</div><p className="text-xs text-muted-foreground">金额最多两位小数;风险价格 < 下限 ≤ 上限 < 目标价格。观察时间限最近 24 小时,有效期最长 7 天。</p><div className="flex gap-3"><Button disabled={busy} type="submit">{busy?"保存中":"保存新草稿"}</Button><Button type="button" variant="outline" disabled={busy} onClick={()=>setEditing(false)}>取消</Button></div></form></CardContent></Card>}
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[.9fr_1.3fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>候选队列</CardTitle><select aria-label="候选状态" className={`${control} max-w-32`} value={status} onChange={e=>setStatus(e.target.value)}><option value="">全部状态</option>{Object.entries(states).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></div><CardDescription>最近 100 条最新修订及当前发布版本。</CardDescription></CardHeader><CardContent className="space-y-2">{loading&&<p className="text-sm text-muted-foreground">正在读取…</p>}{!loading&&list.length===0&&<p className="py-8 text-center text-sm text-muted-foreground">尚无符合此状态的股票候选。点击上方自动筛选,命中结果会进入待审核;无需手工录入。</p>}{list.map(c=><button disabled={busy} key={c.id} onClick={()=>void select(c)} className={`w-full rounded-lg border p-4 text-left transition hover:bg-muted ${selected?.id===c.id?"border-primary bg-accent/40":""}`}><div className="flex items-center justify-between gap-2"><strong className="text-lg">{c.content.name}</strong><Badge variant="outline">{states[c.status]}</Badge></div><p className="mt-1 font-mono text-xs text-muted-foreground">{c.content.exchange} · {c.content.symbol}</p><div className="my-3 flex items-end gap-2"><span className="text-xs text-muted-foreground">参考区间</span><strong className="font-mono text-xl">¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</strong></div><p className="text-xs text-muted-foreground">风险 ¥{c.content.reference_plan.stop_loss} · 目标观察 ¥{c.content.reference_plan.target_price}</p><p className="mt-3 text-sm">{c.content.title}</p><p className="mt-2 text-xs text-muted-foreground">修订 {c.revision} · 版本 {c.version} · {c.is_active_publication?"当前有效发布":c.status==="PUBLISHED"?"已过期或被替代":"模拟研究"}</p></button>)}</CardContent></Card>
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[.9fr_1.3fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>候选队列</CardTitle><select aria-label="候选状态" className={`${control} max-w-32`} value={status} onChange={e=>setStatus(e.target.value)}><option value="">全部状态</option>{Object.entries(states).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></div><CardDescription>最近 100 条最新修订及当前发布版本。</CardDescription></CardHeader><CardContent className="space-y-2">{loading&&<p className="text-sm text-muted-foreground">正在读取…</p>}{!loading&&list.length===0&&<p className="py-8 text-center text-sm text-muted-foreground">尚无符合此状态的股票候选。点击上方自动筛选,命中结果会进入待审核;无需手工录入。</p>}{list.map(c=><button disabled={busy} key={c.id} onClick={()=>void select(c)} className={`w-full rounded-lg border p-4 text-left transition hover:bg-muted ${selected?.id===c.id?"border-primary bg-accent/40":""}`}><div className="flex items-center justify-between gap-2"><strong className="text-lg"><StockIdentity name={c.content.name} symbol={c.content.symbol}/></strong><Badge variant="outline">{states[c.status]}</Badge></div><p className="mt-1 font-mono text-xs text-muted-foreground">{c.content.exchange} · {c.content.symbol}</p><div className="my-3 flex items-end gap-2"><span className="text-xs text-muted-foreground">参考区间</span><strong className="font-mono text-xl">¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</strong></div><p className="text-xs text-muted-foreground">风险 ¥{c.content.reference_plan.stop_loss} · 目标观察 ¥{c.content.reference_plan.target_price}</p><p className="mt-3 text-sm">{c.content.title}</p><p className="mt-2 text-xs text-muted-foreground">修订 {c.revision} · 版本 {c.version} · {c.is_active_publication?"当前有效发布":c.status==="PUBLISHED"?"已过期或被替代":"模拟研究"}</p></button>)}</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle>{selected?`${selected.content.name} · ${selected.content.symbol}`:"股票依据与审核"}</CardTitle><CardDescription>{selected?`${selected.content.name} ${selected.content.exchange}:${selected.content.symbol} · 修订 ${selected.revision}`:"选择候选查看依据与审核记录。"}</CardDescription></CardHeader><CardContent className="space-y-5">{selected&&<><StockHistoryChart symbol={selected.content.symbol} exchange={selected.content.exchange} onSessionExpired={onSessionExpired}/><div className="flex flex-wrap gap-2"><Badge>{states[selected.status]}</Badge><Badge variant="outline">模拟候选</Badge><Badge variant="outline">版本 {selected.version}</Badge></div>{selected.screening&&<div className="rounded-lg border p-4 text-sm leading-7"><strong>自动日线筛选</strong><br/>{selected.screening.strategy_name} · 修订 {selected.screening.strategy_revision}<br/>日线 {selected.screening.bar_date} · {selected.screening.source_adapter} 配置版本 {selected.screening.source_version}<p className="break-all text-xs text-muted-foreground">输入摘要 {selected.screening.input_hash}</p></div>}<p className="text-xs text-muted-foreground">观察 {new Date(selected.observed_at).toLocaleString("zh-CN")} · 有效截止 {new Date(selected.valid_until).toLocaleString("zh-CN")}</p>{([["观察逻辑",selected.content.thesis],["反证与风险",selected.content.counter_evidence],["失效条件",selected.content.invalidation]]).map(([label,value])=><section key={label}><h3 className="mb-2 text-sm font-semibold">{label}</h3><p className="whitespace-pre-wrap text-sm leading-7 text-muted-foreground">{value}</p></section>)}<section><h3 className="mb-3 text-sm font-semibold">证据</h3>{selected.content.evidence.map((e,i)=><div key={i} className="mb-3 rounded-lg bg-muted p-4 text-sm"><a href={e.source_url} target="_blank" rel="noopener noreferrer" className="break-all text-primary underline">{e.locator}</a><p className="mt-2 whitespace-pre-wrap leading-6">{e.summary}</p></div>)}</section><div className="rounded-lg border p-4 text-sm leading-7">参考区间 ¥{selected.content.reference_plan.entry_low}–{selected.content.reference_plan.entry_high}<br/>风险价格 ¥{selected.content.reference_plan.stop_loss} · 目标观察 ¥{selected.content.reference_plan.target_price}</div>
|
||||
<div className="flex flex-wrap gap-2">{selected.is_latest&&selected.status!=="PENDING_REVIEW"&&<Button variant="outline" disabled={busy} onClick={()=>edit(selected)}>创建新修订</Button>}{(selected.is_latest&&selected.status==="DRAFT"?["submit"]:selected.is_latest&&selected.status==="PENDING_REVIEW"?["publish","return"]:selected.is_active_publication?["withdraw"]:[]).map(a=><Button key={a} disabled={busy} variant={a==="publish"?"default":"outline"} onClick={()=>{setAction(a);setReason("")}}>{actions[a]}</Button>)}</div>
|
||||
{action&&<form onSubmit={e=>void confirm(e)} className="space-y-3 rounded-lg border border-primary/30 p-4"><p className="text-sm font-semibold">确认{actions[action]}:{selected.content.name},修订 {selected.revision},版本 {selected.version}</p><p className="text-xs text-muted-foreground">{action==="publish"?"发布后 App 可读取此模拟候选;旧发布版本将被替代。":action==="withdraw"?"撤回后此版本从有效候选列表移除,历史链接仍显示失效状态。":"此操作会写入不可覆盖的审核记录。"}</p><textarea required maxLength={1000} aria-label="审核意见" placeholder="填写具体理由或审核意见" rows={3} className={control} value={reason} onChange={e=>setReason(e.target.value)}/><div className="flex gap-2"><Button disabled={busy||!reason.trim()} type="submit">{busy?"提交中":"确认"}</Button><Button disabled={busy} type="button" variant="ghost" onClick={()=>setAction("")}>取消</Button></div></form>}
|
||||
{action&&<form onSubmit={e=>void confirm(e)} className="space-y-3 rounded-lg border border-primary/30 p-4"><p className="text-sm font-semibold">确认{actions[action]}:{selected.content.name}({selected.content.symbol}),修订 {selected.revision},版本 {selected.version}</p><p className="text-xs text-muted-foreground">{action==="publish"?"发布后 App 可读取此模拟候选;旧发布版本将被替代。":action==="withdraw"?"撤回后此版本从有效候选列表移除,历史链接仍显示失效状态。":"此操作会写入不可覆盖的审核记录。"}</p><textarea required maxLength={1000} aria-label="审核意见" placeholder="填写具体理由或审核意见" rows={3} className={control} value={reason} onChange={e=>setReason(e.target.value)}/><div className="flex gap-2"><Button disabled={busy||!reason.trim()} type="submit">{busy?"提交中":"确认"}</Button><Button disabled={busy} type="button" variant="ghost" onClick={()=>setAction("")}>取消</Button></div></form>}
|
||||
{selected.ai_analysis&&<section className="rounded-xl bg-primary/5 p-4 space-y-3"><h3 className="font-semibold">AI 分析与复核</h3><p className="text-sm leading-7">{selected.ai_analysis.analysis?.summary}</p><p className="text-sm text-muted-foreground">风险:{selected.ai_analysis.analysis?.risks}</p><p className="text-sm leading-7">复核:{selected.ai_analysis.review?.summary}</p><p className="text-xs text-muted-foreground">{selected.ai_analysis.primary_model} / {selected.ai_analysis.review_model}</p></section>}<section><h3 className="mb-3 text-sm font-semibold">审核记录</h3>{selected.reviews?.length?selected.reviews.map(r=><div key={r.id} className="border-t py-3 text-sm"><strong>{actions[r.decision.toLowerCase()]??r.decision}</strong><span className="ml-3 text-xs text-muted-foreground">{new Date(r.created_at).toLocaleString("zh-CN")} · 输入版本 {r.input_version}</span><p className="mt-2 whitespace-pre-wrap text-muted-foreground">{r.reason}</p></div>):<p className="text-sm text-muted-foreground">尚无审核记录。</p>}</section></>}</CardContent></Card></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {StockIdentity} from "./StockIdentity"
|
||||
import {StockHistoryChart} from "./StockHistoryChart"
|
||||
import {useEffect,useState} from "react"
|
||||
import {ApiError,request} from "@/lib/auth"
|
||||
@@ -11,5 +12,5 @@ export function MarketPanel({onSessionExpired,initialSymbol}:{onSessionExpired:(
|
||||
function fail(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"行情读取失败")}
|
||||
async function load(){setBusy(true);setError("");try{const [stocks,runs]=await Promise.all([request<Instrument[]>(`/api/admin/v1/instruments?q=${encodeURIComponent(query)}`),request<Sync[]>("/api/admin/v1/market/syncs")]);setList(stocks);setTasks(runs)}catch(e){fail(e)}finally{setBusy(false)}}
|
||||
useEffect(()=>{void load()},[])
|
||||
return <Card className="mt-6"><CardHeader><CardTitle>沪深股票池 · 行情与日线</CardTitle></CardHeader><CardContent className="space-y-5"><p className="text-sm leading-7 text-muted-foreground">启用公开行情适配器后,后端启动即同步沪深股票,此后每五分钟更新。此接口用于开发研究,价格按供应商时间展示,休市或同步失败时保留上次快照。不会把抓取时间当成实时行情时间。</p><form className="flex gap-3" onSubmit={e=>{e.preventDefault();void load()}}><input className="w-full rounded-lg border px-3 py-2 text-sm" placeholder="按股票代码或名称搜索" value={query} onChange={e=>setQuery(e.target.value)}/><Button disabled={busy}>查询 / 刷新</Button></form>{error&&<p className="text-destructive text-sm">{error}</p>}<div className="max-h-[560px] overflow-auto"><table className="w-full text-left text-sm"><thead><tr>{["股票","价格","行情时间"].map(v=><th key={v} className="p-3">{v}</th>)}</tr></thead><tbody>{list.map(v=><tr className="border-t" key={v.id}><td className="p-3"><button className="text-left text-base font-medium hover:text-primary" onClick={()=>setSelected(v)}>{v.name}</button><p className="text-xs text-muted-foreground">{v.exchange}:{v.symbol}</p></td><td className="font-mono text-lg">{v.price==="0.00"?"无报价":v.price+" 元"}</td><td className="text-xs">{new Date(v.observed_at).toLocaleString("zh-CN")}</td></tr>)}</tbody></table>{list.length===0&&<p className="p-3 text-muted-foreground">暂无同步数据,请检查系统设置中的行情源与同步记录。</p>}</div>{selected&&<div><div className="mb-4 flex items-end justify-between"><div><h2 className="text-xl font-semibold">{selected.name}</h2><p className="mt-1 font-mono text-xs text-muted-foreground">{selected.exchange}:{selected.symbol}</p></div><strong className="font-mono text-3xl">¥{selected.price}</strong></div><StockHistoryChart symbol={selected.symbol} exchange={selected.exchange} onSessionExpired={onSessionExpired}/></div>}<details><summary className="cursor-pointer text-sm">最近同步记录</summary><div className="mt-3 space-y-2">{tasks.length===0&&<p className="text-sm text-muted-foreground">还没有同步记录。</p>}{tasks.map(t=><div key={t.id} className="rounded-lg bg-muted p-3 text-xs">{new Date(t.finished_at).toLocaleString("zh-CN")} · {t.status==="COMPLETED"?`同步 ${t.instrument_count} 个证券`:t.message}</div>)}</div></details></CardContent></Card>
|
||||
return <Card className="mt-6"><CardHeader><CardTitle>沪深股票池 · 行情与日线</CardTitle></CardHeader><CardContent className="space-y-5"><p className="text-sm leading-7 text-muted-foreground">启用公开行情适配器后,后端启动即同步沪深股票,此后每五分钟更新。此接口用于开发研究,价格按供应商时间展示,休市或同步失败时保留上次快照。不会把抓取时间当成实时行情时间。</p><form className="flex gap-3" onSubmit={e=>{e.preventDefault();void load()}}><input className="w-full rounded-lg border px-3 py-2 text-sm" placeholder="按股票代码或名称搜索" value={query} onChange={e=>setQuery(e.target.value)}/><Button disabled={busy}>查询 / 刷新</Button></form>{error&&<p className="text-destructive text-sm">{error}</p>}<div className="max-h-[560px] overflow-auto"><table className="w-full text-left text-sm"><thead><tr>{["股票","价格","行情时间"].map(v=><th key={v} className="p-3">{v}</th>)}</tr></thead><tbody>{list.map(v=><tr className="border-t" key={v.id}><td className="p-3"><button className="text-left text-base font-medium hover:text-primary" onClick={()=>setSelected(v)}><StockIdentity name={v.name} symbol={v.symbol}/></button><p className="text-xs text-muted-foreground">{v.exchange}:{v.symbol}</p></td><td className="font-mono text-lg">{v.price==="0.00"?"无报价":v.price+" 元"}</td><td className="text-xs">{new Date(v.observed_at).toLocaleString("zh-CN")}</td></tr>)}</tbody></table>{list.length===0&&<p className="p-3 text-muted-foreground">{query.trim()?"未找到匹配股票,请尝试股票代码或其他名称。":"暂无同步数据,请检查系统设置中的行情源与同步记录。"}</p>}</div>{selected&&<div><div className="mb-4 flex items-end justify-between"><div><h2 className="text-xl font-semibold"><StockIdentity name={selected.name} symbol={selected.symbol}/></h2><p className="mt-1 font-mono text-xs text-muted-foreground">{selected.exchange}:{selected.symbol}</p></div><strong className="font-mono text-3xl">¥{selected.price}</strong></div><StockHistoryChart symbol={selected.symbol} exchange={selected.exchange} onSessionExpired={onSessionExpired}/></div>}<details><summary className="cursor-pointer text-sm">最近同步记录</summary><div className="mt-3 space-y-2">{tasks.length===0&&<p className="text-sm text-muted-foreground">还没有同步记录。</p>}{tasks.map(t=><div key={t.id} className="rounded-lg bg-muted p-3 text-xs">{new Date(t.finished_at).toLocaleString("zh-CN")} · {t.status==="COMPLETED"?`同步 ${t.instrument_count} 个证券`:t.message}</div>)}</div></details></CardContent></Card>
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import {request,ApiError} from "@/lib/auth"
|
||||
type Bar={date:string;open:string;high:string;low:string;close:string}
|
||||
type History={source:string;adjustment:string;received_at:string;bars:Bar[]}
|
||||
export function StockHistoryChart({symbol,exchange,onSessionExpired}:{symbol:string;exchange:string;onSessionExpired:()=>void}){
|
||||
const [generation,reload]=useState(0)
|
||||
const [data,setData]=useState<History|null>(null),[error,setError]=useState("")
|
||||
useEffect(()=>{const c=new AbortController();setData(null);setError("");const end=new Date(),start=new Date();start.setDate(start.getDate()-90);request<History>(`/api/admin/v1/market/history?symbol=${symbol}&exchange=${exchange}&start=${start.toISOString().slice(0,10)}&end=${end.toISOString().slice(0,10)}`,{signal:c.signal}).then(result=>{if(!c.signal.aborted)setData(result)}).catch(e=>{if(c.signal.aborted)return;if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"日线读取失败")});return()=>c.abort()},[symbol,exchange])
|
||||
useEffect(()=>{const c=new AbortController();setData(null);setError("");const end=new Date(),start=new Date();start.setDate(start.getDate()-90);request<History>(`/api/admin/v1/market/history?symbol=${symbol}&exchange=${exchange}&start=${start.toISOString().slice(0,10)}&end=${end.toISOString().slice(0,10)}`,{signal:c.signal}).then(result=>{if(!c.signal.aborted)setData(result)}).catch(e=>{if(c.signal.aborted)return;if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof ApiError&&e.status===503?"行情源暂时未返回日线,请重试;已同步报价仍可查看。":e instanceof Error?e.message:"日线读取失败")});return()=>c.abort()},[symbol,exchange,generation])
|
||||
const bars=data?.bars.slice(-40)??[]
|
||||
// Integer cents for price parsing; division below only maps values to SVG pixels.
|
||||
const cents=(price:string)=>{const [whole,fraction=""]=price.split(".");return Number(whole)*100+Number(fraction.padEnd(2,"0"))}
|
||||
const values=bars.map(b=>cents(b.close)),min=Math.min(...values),max=Math.max(...values),range=max-min||1
|
||||
const points=values.map((value,i)=>`${20+i*600/Math.max(values.length-1,1)},${150-(value-min)*120/range}`).join(" ")
|
||||
return <div className="rounded-xl border bg-card p-4"><div className="flex items-center justify-between"><strong className="text-sm">日线收盘走势</strong><span className="text-xs text-muted-foreground">未复权 · 最近 {bars.length} 个交易日</span></div>{error?<p className="mt-5 text-sm text-destructive">{error}</p>:!data?<p className="py-12 text-center text-sm text-muted-foreground">正在获取真实日线…</p>:bars.length===0?<p className="py-12 text-center text-sm text-muted-foreground">此范围没有可用日线。</p>:<><svg viewBox="0 0 640 185" className="mt-4 w-full" role="img" aria-label={`${exchange}:${symbol} 真实日线收盘价`}><path d="M20 30H620 M20 90H620 M20 150H620" fill="none" stroke="var(--color-border)" strokeDasharray="4 4"/><polyline points={points} fill="none" stroke="var(--primary)" strokeWidth="2.5" strokeLinejoin="round"/><text x="20" y="178" fontSize="11" fill="#79736c">{bars[0].date}</text><text x="620" y="178" textAnchor="end" fontSize="11" fill="#79736c">{bars[bars.length-1].date}</text></svg><p className="text-xs text-muted-foreground">末日收盘 ¥{bars[bars.length-1].close} · 来源 {data.source} · 接收 {new Date(data.received_at).toLocaleTimeString("zh-CN")}</p></>}</div>
|
||||
return <div className="rounded-xl border bg-card p-4"><div className="flex items-center justify-between"><strong className="text-sm">日线收盘走势</strong><span className="text-xs text-muted-foreground">{data?`未复权 · 最近 ${bars.length} 个交易日`:error?"读取失败":"正在读取"}</span></div>{error?<div className="mt-5 space-y-3"><p className="text-sm text-destructive">{error}</p><button className="rounded-lg border px-3 py-2 text-sm hover:bg-muted" onClick={()=>reload(v=>v+1)}>重试日线</button></div>:!data?<p className="py-12 text-center text-sm text-muted-foreground">正在获取真实日线…</p>:bars.length===0?<p className="py-12 text-center text-sm text-muted-foreground">此范围没有可用日线。</p>:<><svg viewBox="0 0 640 185" className="mt-4 w-full" role="img" aria-label={`${exchange}:${symbol} 真实日线收盘价`}><path d="M20 30H620 M20 90H620 M20 150H620" fill="none" stroke="var(--color-border)" strokeDasharray="4 4"/><polyline points={points} fill="none" stroke="var(--primary)" strokeWidth="2.5" strokeLinejoin="round"/><text x="20" y="178" fontSize="11" fill="#79736c">{bars[0].date}</text><text x="620" y="178" textAnchor="end" fontSize="11" fill="#79736c">{bars[bars.length-1].date}</text></svg><p className="text-xs text-muted-foreground">末日收盘 ¥{bars[bars.length-1].close} · 来源 {data.source} · 接收 {new Date(data.received_at).toLocaleTimeString("zh-CN")}</p></>}</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
type StockIdentityProps = { name: string; symbol: string }
|
||||
|
||||
export function StockIdentity({ name, symbol }: StockIdentityProps) {
|
||||
return <span className="inline-flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span>{name}</span>
|
||||
<span className="font-mono text-[0.85em] font-semibold tabular-nums">{symbol}</span>
|
||||
</span>
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {StockIdentity} from "./StockIdentity"
|
||||
import {useEffect,useRef,useState} from "react"
|
||||
import {ArrowUpRight,RefreshCw} from "lucide-react"
|
||||
import {request,ApiError} from "@/lib/auth"
|
||||
@@ -20,7 +21,7 @@ export function TradingOverview({onSessionExpired,onNavigate}:{onSessionExpired:
|
||||
return <div className="space-y-6"><div className="flex items-center justify-between"><div className="flex gap-2"><Badge variant="outline">沪深 A 股</Badge><Badge variant="outline">公开行情 · 开发研究</Badge></div><Button variant="outline" disabled={loading} onClick={()=>void refresh()}><RefreshCw className={loading?"animate-spin":""}/>刷新数据</Button></div>{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">{[{title:"已同步股票",value:success?.instrument_count??"—",note:success?`最近成功同步 ${new Date(success.finished_at).toLocaleTimeString("zh-CN")}`:"等待行情源同步",page:"股票行情"},{title:"待审核股票候选",value:data?pending.length:"—",note:"最近 100 条候选修订",page:"候选审核"},{title:"有效发布候选",value:data?published.length:"—",note:"最近 100 条候选修订",page:"候选审核"},{title:"已启用研究策略",value:data?data.strategies.filter(s=>s.status==="ACTIVE").length:"—",note:"最近 100 个策略 · 非收益指标",page:"策略中心"}].map(x=><button key={x.title} className="rounded-xl border bg-card p-5 text-left transition hover:border-primary/40" onClick={()=>onNavigate(x.page)}><div className="flex items-center justify-between"><span className="text-sm text-muted-foreground">{x.title}</span><ArrowUpRight size={15} className="text-muted-foreground"/></div><p className="my-3 font-mono text-4xl font-semibold tracking-tight">{x.value}</p><p className="text-xs text-muted-foreground">{x.note}</p></button>)}</div>
|
||||
<ScreeningRunControl onSessionExpired={onSessionExpired} onCompleted={()=>void refresh()}/>
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[1.35fr_1fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>股票观察</CardTitle><Button variant="ghost" size="sm" onClick={()=>onNavigate("股票行情")}>查看股票池<ArrowUpRight/></Button></div><p className="mt-2 text-xs text-muted-foreground">按证券代码排序的行情样本 · 非涨幅排名或投资推荐</p></CardHeader><CardContent>{selected&&<div className="mb-5"><div className="mb-4 flex items-end justify-between"><div><button className="text-xl font-semibold hover:text-primary" onClick={()=>onNavigate("股票行情",selected.symbol)}>{selected.name}</button><p className="mt-1 font-mono text-xs text-muted-foreground">{selected.exchange} · {selected.symbol}</p></div><div className="text-right"><p className="font-mono text-3xl font-semibold">¥{selected.price}</p><p className="mt-1 text-xs text-muted-foreground">报价 {new Date(selected.observed_at).toLocaleString("zh-CN")}</p></div></div><StockHistoryChart symbol={selected.symbol} exchange={selected.exchange} onSessionExpired={onSessionExpired}/></div>}<div className="overflow-auto"><table className="w-full text-left text-sm"><thead className="text-xs text-muted-foreground"><tr><th className="pb-3">股票 / 代码</th><th className="pb-3 text-right">最新报价</th><th className="pb-3 text-right">行情时间</th></tr></thead><tbody>{data?.stocks.slice(0,8).map(stock=><tr key={stock.id} className="border-t"><td className="py-3"><button className="text-left hover:text-primary" onClick={()=>setSelected(stock)}><strong>{stock.name}</strong><p className="mt-1 font-mono text-xs text-muted-foreground">{stock.exchange}:{stock.symbol}</p></button></td><td className="text-right font-mono">¥{stock.price}</td><td className="text-right text-xs text-muted-foreground">{new Date(stock.observed_at).toLocaleTimeString("zh-CN")}</td></tr>)}</tbody></table>{data?.stocks.length===0&&<p className="py-8 text-sm text-muted-foreground">暂无股票快照,请在行情源设置中检查同步配置。</p>}</div></CardContent></Card>
|
||||
<Card><CardHeader><div className="flex items-center justify-between"><CardTitle>待审核信号</CardTitle><Button variant="ghost" size="sm" onClick={()=>onNavigate("候选审核")}>进入审核<ArrowUpRight/></Button></div><p className="mt-2 text-xs text-muted-foreground">从股票、参考区间与信号依据开始审核</p></CardHeader><CardContent className="space-y-3">{pending.length===0&&<div className="rounded-xl border border-dashed p-6 text-sm leading-7 text-muted-foreground">当前没有待审核股票。点击“立即自动筛选”后,真实日线命中结果会自动进入这里;不符合规则的股票不会生成候选。</div>}{pending.slice(0,8).map(c=><button key={c.id} className="w-full rounded-xl border p-4 text-left hover:border-primary/40" onClick={()=>onNavigate("候选审核",undefined,c.id)}><div className="flex items-center justify-between"><strong className="text-lg">{c.content.name}</strong><Badge variant="outline">待审核</Badge></div><p className="mt-1 font-mono text-xs text-muted-foreground">{c.content.exchange} · {c.content.symbol}</p><p className="my-3 font-mono text-xl">¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</p><p className="text-xs leading-6 text-muted-foreground">{c.screening?`${c.screening.strategy_name} · 日线 ${c.screening.bar_date}`:"人工补充研究"}<br/>有效至 {new Date(c.valid_until).toLocaleString("zh-CN")}</p></button>)}</CardContent></Card></div>
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[1.35fr_1fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>股票观察</CardTitle><Button variant="ghost" size="sm" onClick={()=>onNavigate("股票行情")}>查看股票池<ArrowUpRight/></Button></div><p className="mt-2 text-xs text-muted-foreground">按证券代码排序的行情样本 · 非涨幅排名或投资推荐</p></CardHeader><CardContent>{selected&&<div className="mb-5"><div className="mb-4 flex items-end justify-between"><div><button className="text-xl font-semibold hover:text-primary" onClick={()=>onNavigate("股票行情",selected.symbol)}><StockIdentity name={selected.name} symbol={selected.symbol}/></button><p className="mt-1 font-mono text-xs text-muted-foreground">{selected.exchange} · {selected.symbol}</p></div><div className="text-right"><p className="font-mono text-3xl font-semibold">¥{selected.price}</p><p className="mt-1 text-xs text-muted-foreground">报价 {new Date(selected.observed_at).toLocaleString("zh-CN")}</p></div></div><StockHistoryChart symbol={selected.symbol} exchange={selected.exchange} onSessionExpired={onSessionExpired}/></div>}<div className="overflow-auto"><table className="w-full text-left text-sm"><thead className="text-xs text-muted-foreground"><tr><th className="pb-3">股票 / 代码</th><th className="pb-3 text-right">最新报价</th><th className="pb-3 text-right">行情时间</th></tr></thead><tbody>{data?.stocks.slice(0,8).map(stock=><tr key={stock.id} className="border-t"><td className="py-3"><button className="text-left hover:text-primary" onClick={()=>setSelected(stock)}><strong><StockIdentity name={stock.name} symbol={stock.symbol}/></strong><p className="mt-1 font-mono text-xs text-muted-foreground">{stock.exchange}:{stock.symbol}</p></button></td><td className="text-right font-mono">¥{stock.price}</td><td className="text-right text-xs text-muted-foreground">{new Date(stock.observed_at).toLocaleTimeString("zh-CN")}</td></tr>)}</tbody></table>{data?.stocks.length===0&&<p className="py-8 text-sm text-muted-foreground">暂无股票快照,请在行情源设置中检查同步配置。</p>}</div></CardContent></Card>
|
||||
<Card><CardHeader><div className="flex items-center justify-between"><CardTitle>待审核信号</CardTitle><Button variant="ghost" size="sm" onClick={()=>onNavigate("候选审核")}>进入审核<ArrowUpRight/></Button></div><p className="mt-2 text-xs text-muted-foreground">从股票、参考区间与信号依据开始审核</p></CardHeader><CardContent className="space-y-3">{pending.length===0&&<div className="rounded-xl border border-dashed p-6 text-sm leading-7 text-muted-foreground">当前没有待审核股票。点击“立即自动筛选”后,真实日线命中结果会自动进入这里;不符合规则的股票不会生成候选。</div>}{pending.slice(0,8).map(c=><button key={c.id} className="w-full rounded-xl border p-4 text-left hover:border-primary/40" onClick={()=>onNavigate("候选审核",undefined,c.id)}><div className="flex items-center justify-between"><strong className="text-lg"><StockIdentity name={c.content.name} symbol={c.content.symbol}/></strong><Badge variant="outline">待审核</Badge></div><p className="mt-1 font-mono text-xs text-muted-foreground">{c.content.exchange} · {c.content.symbol}</p><p className="my-3 font-mono text-xl">¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</p><p className="text-xs leading-6 text-muted-foreground">{c.screening?`${c.screening.strategy_name} · 日线 ${c.screening.bar_date}`:"人工补充研究"}<br/>有效至 {new Date(c.valid_until).toLocaleString("zh-CN")}</p></button>)}</CardContent></Card></div>
|
||||
<p className="text-xs leading-6 text-muted-foreground">价格按供应商观察时间展示,收盘后保留收盘快照。曲线来自实际未复权日线;当前未接入指数、涨跌幅、成交额与行业分类,因此不展示相应统计。</p></div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user