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

统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
2026-10-08 22:58:52 +08:00
parent bffdb3858b
commit 92329ddce2
145 changed files with 18593 additions and 587 deletions
@@ -0,0 +1,12 @@
import {router} from "expo-router"
import {Text,View} from "react-native"
import {useAccounts} from "../state/accounts"
import {formatMoney} from "../lib/money"
import {Body,Button,EmptyState,ErrorMessage,Panel,styles} from "./ui"
export function AccountSummary(){
const {selected,isPending,isError,error,refetch,isRefetching}=useAccounts()
if(isPending)return <Body>正在读取账户…</Body>
if(isError)return <Panel><ErrorMessage message={error.message}/><Button title="重新读取账户" secondary busy={isRefetching} onPress={()=>void refetch()}/></Panel>
if(!selected)return <Panel><EmptyState title="从一个账户开始" detail="创建模拟账户练习决策,或使用真实记录账户记录自行发生的交易。"/><Button title="创建账户" onPress={()=>router.push("/settings/accounts")}/></Panel>
return <Panel><View style={styles.row}><Text style={styles.subtitle}>{selected.name}</Text><Text style={styles.badge}>{selected.mode==="simulation"?"模拟账户":"手工真实记录"}</Text></View><Body>{selected.mode==="simulation"?"账本可用现金(元)":"账本估算可用现金(元)"}</Body><Text style={styles.money}>{formatMoney(selected.cash_balance)}</Text><Body>{selected.mode==="manual_real"?"用户记录,未经券商核验。现金账本尚不完整,余额显示未知。":"现金由初始资金与账本流水计算;提醒不会生成成交。"}</Body><View style={styles.row}><Button title="管理账户" secondary onPress={()=>router.push("/settings/accounts")}/><Button title="刷新" secondary busy={isRefetching} onPress={()=>void refetch()}/></View></Panel>
}
+13
View File
@@ -0,0 +1,13 @@
import type {PropsWithChildren} from "react"
import {ActivityIndicator,KeyboardAvoidingView,Platform,Pressable,ScrollView,StyleSheet,Text,TextInput,View,type TextInputProps,type ViewStyle} from "react-native"
import {SafeAreaView} from "react-native-safe-area-context"
export const colors={background:"#FFFFFF",text:"#1B1B20",muted:"#74747F",layer:"#F5F5F7",action:"#202025",accent:"#6554A5",accentSoft:"#F1EDF9",border:"#EAEAF0",danger:"#C54655"}
export function Screen({children}:PropsWithChildren){return <SafeAreaView style={styles.safe} edges={["top","left","right"]}><KeyboardAvoidingView style={styles.safe} behavior={Platform.OS==="ios"?"padding":undefined}><ScrollView contentContainerStyle={styles.screen} keyboardShouldPersistTaps="handled">{children}</ScrollView></KeyboardAvoidingView></SafeAreaView>}
export function Title({children}:PropsWithChildren){return <Text accessibilityRole="header" style={styles.title}>{children}</Text>}
export function Body({children}:PropsWithChildren){return <Text style={styles.body}>{children}</Text>}
export function Panel({children,accent=false,style}:PropsWithChildren<{accent?:boolean;style?:ViewStyle}>){return <View style={[styles.panel,accent&&{backgroundColor:colors.accentSoft},style]}>{children}</View>}
export function Button({title,onPress,disabled=false,secondary=false,busy=false}:{title:string;onPress:()=>void;disabled?:boolean;secondary?:boolean;busy?:boolean}){return <Pressable accessibilityRole="button" accessibilityState={{disabled:disabled||busy,busy}} disabled={disabled||busy} onPress={onPress} style={({pressed})=>[styles.button,secondary&&styles.secondary,(disabled||busy||pressed)&&{opacity:0.55}]}>{busy?<ActivityIndicator color={secondary?colors.text:"#FFFFFF"}/>:<Text style={[styles.buttonText,secondary&&{color:colors.text}]}>{title}</Text>}</Pressable>}
export function Field({label,...props}:TextInputProps&{label:string}){return <View style={styles.field}><Text style={styles.label}>{label}</Text><TextInput accessibilityLabel={label} placeholderTextColor={colors.muted} style={styles.input} {...props}/></View>}
export function ErrorMessage({message}:{message:string}){return message?<Text accessibilityRole="alert" style={styles.error}>{message}</Text>:null}
export function EmptyState({title,detail}: {title:string;detail:string}){return <View style={styles.empty}><Text style={styles.subtitle}>{title}</Text><Body>{detail}</Body></View>}
export const styles=StyleSheet.create({safe:{flex:1,backgroundColor:colors.background},screen:{padding:24,paddingBottom:40,gap:22,flexGrow:1},title:{fontSize:29,fontWeight:"700",color:colors.text,lineHeight:38},subtitle:{fontSize:19,fontWeight:"600",color:colors.text},body:{fontSize:14,lineHeight:24,color:colors.muted},panel:{backgroundColor:colors.layer,padding:22,borderRadius:18,gap:14},button:{backgroundColor:colors.action,minHeight:50,paddingVertical:13,paddingHorizontal:18,borderRadius:12,alignItems:"center",justifyContent:"center"},secondary:{backgroundColor:colors.layer},buttonText:{fontSize:15,fontWeight:"600",color:"#FFFFFF"},field:{gap:10},label:{fontSize:14,color:colors.text},input:{minHeight:52,borderRadius:12,borderWidth:1,borderColor:colors.border,paddingHorizontal:15,paddingVertical:12,fontSize:16,color:colors.text,backgroundColor:"#FFFFFF"},error:{fontSize:14,color:colors.danger,lineHeight:23},empty:{paddingVertical:35,gap:12},row:{flexDirection:"row",alignItems:"center",justifyContent:"space-between",gap:12},money:{fontSize:34,fontWeight:"600",color:colors.text},badge:{fontSize:12,color:colors.accent,backgroundColor:colors.accentSoft,paddingHorizontal:10,paddingVertical:6,borderRadius:8,alignSelf:"flex-start"},divider:{height:1,backgroundColor:colors.border}})
+37
View File
@@ -0,0 +1,37 @@
export class ApiError extends Error {
readonly status:number
readonly code:string
constructor(status:number,code:string,message:string){super(message);this.name="ApiError";this.status=status;this.code=code}
}
export function normalizeBaseURL(value:string):string {
if(!value.trim())throw new ApiError(0,"NOT_CONFIGURED","请先配置 App 的 API 地址。")
let url:URL
try{url=new URL(value.trim())}catch{throw new ApiError(0,"INVALID_URL","API 地址格式不正确。")}
if(!["http:","https:"].includes(url.protocol)||url.username||url.password||url.search||url.hash||url.pathname!=="/")throw new ApiError(0,"INVALID_URL","API 地址应只包含协议、主机和端口。")
return url.origin
}
export type Client = <T>(path:string,options?:{method?:string;body?:unknown;signal?:AbortSignal})=>Promise<T>
export function createClient(baseURL:string,token?:string,onExpired?:()=>void,transport:typeof fetch=fetch,isCurrent:()=>boolean=()=>true):Client {
return async <T>(path:string,options:{method?:string;body?:unknown;signal?:AbortSignal}={}):Promise<T>=>{
const base=normalizeBaseURL(baseURL)
if(!path.startsWith("/api/v1/")||path.includes("?")||path.includes("#"))throw new ApiError(0,"INVALID_PATH","请求地址无效。")
const controller=new AbortController()
const cancel=()=>controller.abort(options.signal?.reason)
if(options.signal?.aborted)cancel();else options.signal?.addEventListener("abort",cancel,{once:true})
const timer=setTimeout(()=>controller.abort(),10000)
try{
const response=await transport(base+path,{method:options.method??"GET",signal:controller.signal,credentials:"omit",redirect:"error",headers:{Accept:"application/json",...(token?{Authorization:`Bearer ${token}`} : {}),...(options.body!==undefined?{"Content-Type":"application/json"}:{})},...(options.body!==undefined?{body:JSON.stringify(options.body)}:{})})
if(token&&!isCurrent())throw new ApiError(401,"SESSION_CHANGED","会话已变更,请重新进入。")
if(response.status===401)onExpired?.()
let envelope:unknown
try{envelope=await response.json()}catch{throw new ApiError(response.status,"INVALID_RESPONSE","服务响应格式异常,请稍后重试。")}
if(!response.ok){const data=envelope as {error?:{code?:string;message?:string}};throw new ApiError(response.status,data?.error?.code??"REQUEST_FAILED",data?.error?.message??"请求失败,请稍后重试。")}
if(!envelope||typeof envelope!=="object"||!("data" in envelope))throw new ApiError(response.status,"INVALID_RESPONSE","服务响应格式异常,请稍后重试。")
return (envelope as {data:T}).data
}catch(error){
if(error instanceof ApiError)throw error
if(options.signal?.aborted)throw error
throw new ApiError(0,"NETWORK_ERROR",controller.signal.aborted?"请求超时,请检查网络后重试。":"无法连接服务,请检查 API 地址与网络。")
}finally{clearTimeout(timer);options.signal?.removeEventListener("abort",cancel)}
}
}
+2
View File
@@ -0,0 +1,2 @@
export type Candidate={id:string;candidate_id:string;revision:number;version:number;status:string;data_mode:"simulation";valid_until:string;observed_at:string;is_active_publication:boolean;withdrawal_reason:string;content:{symbol:string;exchange:string;name:string;title:string;thesis:string;evidence:{source_url:string;locator:string;summary:string}[];counter_evidence:string;invalidation:string;reference_plan:{entry_low:string;entry_high:string;stop_loss:string;target_price:string}}}
export function publicationAvailable(candidate:Pick<Candidate,"status"|"is_active_publication"|"valid_until">,now=Date.now()){return candidate.status==="PUBLISHED"&&candidate.is_active_publication&&Date.parse(candidate.valid_until)>now}
+2
View File
@@ -0,0 +1,2 @@
export type Fill={id:string;account_id:string;plan_id:string|null;idempotency_key:string;side:"BUY"|"SELL";symbol:string;exchange:string;name:string;quantity:string;price:string;fee:string;cash_delta:string;realized_pnl:string;occurred_at:string;sellable_at:string|null;note:string;reversed:boolean}
export type Portfolio={account_id:string;cash_balance:string;realized_pnl:string;review:{trade_count:number;sell_count:number;profitable_sell_count:number;losing_sell_count:number;buy_outflow:string;sell_inflow:string;total_fees:string};positions:{symbol:string;exchange:string;name:string;quantity:string;sellable_quantity:string;cost_basis:string;market_value:string|null;unrealized_pnl:string|null}[]}
+13
View File
@@ -0,0 +1,13 @@
// Money stays a decimal string across the transport and rendering boundary.
export function formatMoney(value:string|null):string {
if(value===null)return "未知"
if(!/^\d+\.\d{2}$/.test(value))return "数据异常"
const [whole,fraction]=value.split(".")
return `${whole.replace(/\B(?=(\d{3})+(?!\d))/g,",")}.${fraction}`
}
export function validInitialCash(value:string):boolean {
if(!/^\d{1,10}(\.\d{1,2})?$/.test(value))return false
const [whole,fraction=""]=value.split(".")
const cents=BigInt(whole)*100n+BigInt(fraction.padEnd(2,"0"))
return cents>0n&&cents<=100000000000n
}
+5
View File
@@ -0,0 +1,5 @@
import type {Candidate} from "./candidates"
export type Preview={id:string;account_id:string;candidate_revision_id:string;quantity:string;reserved_amount:string;fee_budget:string;expires_at:string}
export type Plan={id:string;account_id:string;candidate_revision_id:string;quantity:string;filled_quantity:string;reserved_amount:string;status:string;version:number;valid_until:string;candidate_content:Candidate["content"]}
export type Budget={cash_balance:string;reserved_amount:string;available_amount:string}
export const planStates:Record<string,string>={ACTIVE:"等待条件",PART_FILLED:"部分成交",COMPLETED:"已记录完成",CANCELLED:"已取消",INVALIDATED:"已失效"}
+11
View File
@@ -0,0 +1,11 @@
import type {Session} from "./types.ts"
export function parseSession(value:unknown):Session|null {
if(!value||typeof value!=="object")return null
const data=value as Partial<Session>
if(typeof data.access_token!=="string"||!/^[A-Za-z0-9_-]{43}$/.test(data.access_token)||typeof data.expires_at!=="string"||!Number.isFinite(Date.parse(data.expires_at))||!data.user||typeof data.user.id!=="string"||!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(data.user.id)||typeof data.user.display_name!=="string"||data.user.status!=="ACTIVE")return null
return data as Session
}
export function createStorageQueue(){
let queue:Promise<void>=Promise.resolve()
return (operation:()=>Promise<void>)=>{const next=queue.catch(()=>{}).then(operation);queue=next;return next}
}
+11
View File
@@ -0,0 +1,11 @@
import * as SecureStore from "expo-secure-store"
import {parseSession} from "./session-data"
import type {Session} from "./types"
const key="veyra.app.session.v1"
export async function readSession():Promise<Session|null>{
const text=await SecureStore.getItemAsync(key)
if(!text)return null
try{return parseSession(JSON.parse(text))}catch{return null}
}
export const saveSession=(session:Session)=>SecureStore.setItemAsync(key,JSON.stringify(session),{keychainAccessible:SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY})
export const clearSession=()=>SecureStore.deleteItemAsync(key)
+4
View File
@@ -0,0 +1,4 @@
export type User = {id:string;display_name:string;status:"ACTIVE"|"DISABLED"}
export type Session = {user:User;access_token:string;expires_at:string}
export type Account = {id:string;user_id:string;mode:"simulation"|"manual_real";name:string;currency:"CNY";ledger_status:"COMPLETE"|"INCOMPLETE";cash_balance:string|null;created_at:string}
export type Health = {status:string;components:{postgresql:{status:string};redis:{status:string}};checked_at:string}
+2
View File
@@ -0,0 +1,2 @@
import {create} from "zustand"
export const useAccountSelection=create<{accountID:string|null;select:(id:string|null)=>void}>(set=>({accountID:null,select:accountID=>set({accountID})}))
+11
View File
@@ -0,0 +1,11 @@
import {useQuery,useQueryClient} from "@tanstack/react-query"
import {useSession} from "./session"
import {useAccountSelection} from "./account"
import type {Account} from "../lib/types"
export function useAccounts(){
const {session,client}=useSession();const selectedID=useAccountSelection(state=>state.accountID)
const query=useQuery({queryKey:["accounts",session?.user.id],enabled:!!session,queryFn:({signal})=>client<Account[]>("/api/v1/accounts",{signal})})
const accounts=query.data??[];const selected=accounts.find(account=>account.id===selectedID)??accounts[0]??null
return {...query,accounts,selected}
}
export function useSwitchAccount(){const queryClient=useQueryClient();return async(id:string)=>{await queryClient.cancelQueries();queryClient.removeQueries({predicate:query=>query.queryKey[0]!=="accounts"});useAccountSelection.getState().select(id)}}
+47
View File
@@ -0,0 +1,47 @@
import {createContext,useCallback,useContext,useEffect,useMemo,useRef,useState,type PropsWithChildren} from "react"
import {AppState} from "react-native"
import {QueryClient,QueryClientProvider,focusManager} from "@tanstack/react-query"
import {ApiError,createClient,type Client} from "../lib/api"
import {readSession,saveSession,clearSession} from "../lib/session-storage"
import {parseSession,createStorageQueue} from "../lib/session-data"
import type {Session,User} from "../lib/types"
import {useAccountSelection} from "./account"
const baseURL=process.env.EXPO_PUBLIC_API_URL??""
const SessionContext=createContext<{session:Session|null;loading:boolean;restoreError:string;client:Client;retry:()=>void;login:(name:string,password:string)=>Promise<void>;logout:()=>Promise<void>}|null>(null)
export function SessionProvider({children}:PropsWithChildren){
const [queryClient]=useState(()=>new QueryClient({defaultOptions:{queries:{retry:(count,error)=>!(error instanceof ApiError&&[401,403,404,422].includes(error.status))&&count<1,staleTime:15000}}}))
const [session,setSession]=useState<Session|null>(null)
const [loading,setLoading]=useState(true)
const [restoreError,setRestoreError]=useState("")
const [attempt,setAttempt]=useState(0)
const active=useRef<string|null>(null)
const mounted=useRef(true)
// SecureStore writes must be ordered so an expired session cannot erase a newer login.
const [persist]=useState(()=>createStorageQueue())
const reset=useCallback(()=>{active.current=null;setSession(null);void queryClient.cancelQueries();queryClient.clear();useAccountSelection.getState().select(null);void persist(clearSession).catch(()=>setRestoreError("无法清除本机会话,请重试。"))},[persist,queryClient])
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false}},[])
useEffect(()=>{
let cancelled=false;setLoading(true);setRestoreError("")
async function restore(){try{
const saved=await readSession();if(cancelled)return
if(!saved){setSession(null);return}
if(Date.parse(saved.expires_at)<=Date.now()){await persist(clearSession);return}
const user=await createClient(baseURL,saved.access_token)<User>("/api/v1/me")
if(!cancelled){active.current=saved.access_token;setSession({...saved,user})}
}catch(error){if(cancelled)return;if(error instanceof ApiError&&error.status===401){try{await persist(clearSession)}catch{setRestoreError("无法清除已过期的本机会话,请重试。")}}else setRestoreError(error instanceof Error?error.message:"会话恢复失败,请重试。")}finally{if(!cancelled)setLoading(false)}}
void restore();return()=>{cancelled=true}
},[attempt,persist])
const client=useMemo(()=>{const token=session?.access_token;return createClient(baseURL,token,()=>{if(token&&active.current===token&&mounted.current)reset()},fetch,()=>active.current===token)},[session?.access_token,reset])
useEffect(()=>{const subscription=AppState.addEventListener("change",state=>{focusManager.setFocused(state==="active");if(state==="active"&&session&&Date.parse(session.expires_at)<=Date.now())reset()});return()=>subscription.remove()},[session,reset])
useEffect(()=>{if(!session)return;const timer=setTimeout(reset,Math.max(0,Date.parse(session.expires_at)-Date.now()));return()=>clearTimeout(timer)},[session,reset])
async function login(name:string,password:string){
const response=await createClient(baseURL)<unknown>("/api/v1/auth/login",{method:"POST",body:{login_name:name.trim(),password}})
const result=parseSession(response)
if(!result)throw new ApiError(502,"INVALID_RESPONSE","服务返回的登录信息无效,请重试。")
try{await persist(()=>saveSession(result))}catch{await createClient(baseURL,result.access_token)("/api/v1/auth/logout",{method:"POST"}).catch(()=>{});throw new Error("无法安全保存会话,请检查设备后重试。")}
if(mounted.current){active.current=result.access_token;queryClient.clear();useAccountSelection.getState().select(null);setRestoreError("");setSession(result)}
}
async function logout(){await client("/api/v1/auth/logout",{method:"POST"});active.current=null;setSession(null);await queryClient.cancelQueries();queryClient.clear();useAccountSelection.getState().select(null);try{await persist(clearSession)}catch{setRestoreError("服务端会话已注销,但本机会话清理失败,请重试。")}}
return <QueryClientProvider client={queryClient}><SessionContext.Provider value={{session,loading,restoreError,client,retry:()=>setAttempt(v=>v+1),login,logout}}>{children}</SessionContext.Provider></QueryClientProvider>
}
export function useSession(){const value=useContext(SessionContext);if(!value)throw new Error("SessionProvider is required");return value}