fix: recover transient stock history failures within bounded deadlines #9
@@ -12,17 +12,23 @@
|
||||
- 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 / CI 部署修复。
|
||||
- 部署后的配置校验发现旧数据库 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 合并,待 CI 上线后实查。
|
||||
- 查询统一忽略名称与输入中的空格、制表符和全角空格,保持原始源名称;转义 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)
|
||||
|
||||
- 真机空列表排查:公开 HTTPS 下测试用户账户列表为一个已有模拟账户;候选、计划、提醒接口均成功返回空数组。管理员候选列表两条(中洲控股、胜利股份)仍为 PENDING_REVIEW,策略 ACTIVE,尚未人工审核发布;因此 App 机会列表为空,用户也未创建计划。排查只读业务数据,没有替用户发布候选或保留资金。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user