8 Commits
Author SHA1 Message Date
Blizzard d65b6bc84f Merge pull request 'fix: retain stock identities in research and AI task lists' (#8) from dev into master
Veyra CI/CD / verify (push) Successful in 5m39s
Veyra CI/CD / deploy (push) Successful in 3m14s
2026-10-09 15:08:59 +00:00
Blizzard 5003a44d2b Merge pull request 'fix: display stock codes alongside names across admin views' (#7) from dev into master
Veyra CI/CD / verify (push) Successful in 5m18s
Veyra CI/CD / deploy (push) Successful in 2m20s
2026-10-09 14:58:08 +00:00
Blizzard 7ad49c4064 Merge pull request 'fix: match padded stock names in security searches' (#6) from dev into master
Veyra CI/CD / verify (push) Successful in 4m33s
Veyra CI/CD / deploy (push) Canceled after 0s
2026-10-09 14:56:35 +00:00
Blizzard 393f83bb9a Merge pull request 'fix: persist configurable model request deadlines' (#5) from dev into master
Veyra CI/CD / verify (push) Successful in 4m23s
Veyra CI/CD / deploy (push) Canceled after 0s
2026-10-09 14:53:53 +00:00
Blizzard b47889c5f3 Merge pull request '修复 CI 源码下载截断并重试发布' (#4) from dev into master
Veyra CI/CD / verify (push) Successful in 7m31s
Veyra CI/CD / deploy (push) Successful in 1m23s
2026-10-09 14:38:02 +00:00
Blizzard bf643b0363 Merge pull request '接入 AI 候选分析、自动审核发布与 App 真机修复' (#3) from dev into master
Veyra CI/CD / verify (push) Failing after 20s
Veyra CI/CD / deploy (push) Skipped
完整本机检查通过;由 master 合并触发 CI 验证和发布。
2026-10-09 14:34:15 +00:00
Blizzard 825d049e32 Merge pull request 'ci: 复用服务器构建缓存与 HTTPS 登录配置' (#2) from dev into master
Veyra CI/CD / verify (push) Successful in 8m30s
Veyra CI/CD / deploy (push) Successful in 1m34s
2026-10-09 13:12:09 +00:00
Blizzard abcb1c99e8 Merge pull request 'feat: 自动候选筛选、股票研究工作台与 master 自动部署' (#1) from dev into master
Veyra CI/CD / deploy (push) Canceled after 0s
Veyra CI/CD / verify (push) Canceled after 20m38s
2026-10-09 12:49:53 +00:00
6 changed files with 6 additions and 85 deletions
+2 -8
View File
@@ -12,23 +12,17 @@
- 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 秒已成功保存。
- 部署后的配置校验发现旧数据库 timeout_seconds 上限仍为 30,已新增十六版迁移匹配调用端 120 秒上限,并使用实际数据库隔离 schema 测试 120 秒配置持久化;Go race 与 go vet 通过,正在通过 PR / CI 部署修复。
## 股票名称搜索修复(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 成功,因已被新版包含而取消待部署任务,未删除记录。
- 查询统一忽略名称与输入中的空格、制表符和全角空格,保持原始源名称;转义 LIKE 通配符为字面匹配,避免输入 % 或 _ 返回全部股票。管理端无搜索结果文案与无同步数据区分。真实 PostgreSQL 隔离 schema 的 race 回归覆盖名称/代码/空格和通配符,go vet、管理端类型检查/build/embed 通过;已通过 PR #6 合并,待 CI 上线后实查。
- 按用户补充要求,管理端行情列表/详情、候选队列、审核详情/确认、总览观察与信号卡将代码与名称并列展示,代码使用等宽字形;选股、回测详情、策略证券列表以及 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)
- 真机空列表排查:公开 HTTPS 下测试用户账户列表为一个已有模拟账户;候选、计划、提醒接口均成功返回空数组。管理员候选列表两条(中洲控股、胜利股份)仍为 PENDING_REVIEW,策略 ACTIVE,尚未人工审核发布;因此 App 机会列表为空,用户也未创建计划。排查只读业务数据,没有替用户发布候选或保留资金。
+1 -8
View File
@@ -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(), requestTimeout(c.Request))
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
if identity.PublicOrigin != "" {
ctx = context.WithValue(ctx, publicOriginContextKey{}, identity.PublicOrigin)
@@ -87,10 +87,3 @@ 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
}
-3
View File
@@ -90,9 +90,6 @@ 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
}
-62
View File
@@ -1,62 +0,0 @@
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)
}
})
}
}
+1 -1
View File
@@ -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: 40 * 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: 10 * 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))
@@ -3,13 +3,12 @@ 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 ApiError&&e.status===503?"行情源暂时未返回日线,请重试;已同步报价仍可查看。":e instanceof Error?e.message:"日线读取失败")});return()=>c.abort()},[symbol,exchange,generation])
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])
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">{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>
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>
}