Compare commits
4 Commits
8031f27f85
...
fa89b382df
| Author | SHA1 | Date | |
|---|---|---|---|
| fa89b382df | |||
| e843d7556d | |||
| 580743853a | |||
| 817d130fd3 |
@@ -38,6 +38,7 @@ const ALL_PARTS: PartEntry[] = [
|
|||||||
{ key: 'instruments_etf', label: 'ETF 列表' },
|
{ key: 'instruments_etf', label: 'ETF 列表' },
|
||||||
{ key: 'financials', label: '财务数据' },
|
{ key: 'financials', label: '财务数据' },
|
||||||
{ key: 'pools', label: '板块池' },
|
{ key: 'pools', label: '板块池' },
|
||||||
|
{ key: 'ext_data', label: '扩展数据(概念/行业)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function fmtSize(bytes: number): string {
|
function fmtSize(bytes: number): string {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""TickFlow Stock Panel backend."""
|
"""A股工作台 backend."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from app.services.ext_data import (
|
|||||||
write_ext_parquet,
|
write_ext_parquet,
|
||||||
rows_to_parquet,
|
rows_to_parquet,
|
||||||
)
|
)
|
||||||
from app.services.ext_pull import fetch_and_ingest, pull_scheduler
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/ext-data", tags=["ext-data"])
|
router = APIRouter(prefix="/api/ext-data", tags=["ext-data"])
|
||||||
@@ -330,20 +329,8 @@ async def fetch_preset_data(request: Request, config_id: str):
|
|||||||
"""手动触发内置预设 (概念/行业) 的数据拉取。
|
"""手动触发内置预设 (概念/行业) 的数据拉取。
|
||||||
|
|
||||||
注意: 必须在 /{config_id}/... 动态路由之前声明, 否则 'presets' 会被当成 config_id。
|
注意: 必须在 /{config_id}/... 动态路由之前声明, 否则 'presets' 会被当成 config_id。
|
||||||
与通用 pull/run 不同: 走 ext_presets 的结构转换 (接口的 concepts/industries
|
|
||||||
数组 → 拼接成字符串), 保证 schema 与现有数据一致。
|
|
||||||
"""
|
"""
|
||||||
from app.services.ext_presets import fetch_preset
|
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
|
||||||
|
|
||||||
try:
|
|
||||||
n = await fetch_preset(config_id, _data_dir(request))
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(404, str(e)) from e
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(400, f"拉取失败: {e}") from e
|
|
||||||
|
|
||||||
_refresh_views(request)
|
|
||||||
return {"status": "ok", "rows": n}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
@@ -548,115 +535,20 @@ def ingest_data(request: Request, config_id: str, body: IngestReq):
|
|||||||
|
|
||||||
@router.put("/{config_id}/pull")
|
@router.put("/{config_id}/pull")
|
||||||
def configure_pull(request: Request, config_id: str, body: PullConfigReq):
|
def configure_pull(request: Request, config_id: str, body: PullConfigReq):
|
||||||
"""配置(或更新)定时拉取。"""
|
"""配置(或更新)定时拉取 — serve 端为只读模式,已禁用。"""
|
||||||
store = _store(request)
|
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
|
||||||
config = store.get(config_id)
|
|
||||||
if not config:
|
|
||||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
|
||||||
|
|
||||||
# 保留历史状态字段
|
|
||||||
old_pull = config.pull
|
|
||||||
config.pull = PullConfig(
|
|
||||||
url=body.url,
|
|
||||||
method=body.method,
|
|
||||||
headers=body.headers,
|
|
||||||
body=body.body,
|
|
||||||
response_path=body.response_path,
|
|
||||||
field_map=body.field_map,
|
|
||||||
schedule_minutes=body.schedule_minutes,
|
|
||||||
enabled=body.enabled,
|
|
||||||
last_run=old_pull.last_run if old_pull else None,
|
|
||||||
last_status=old_pull.last_status if old_pull else None,
|
|
||||||
last_message=old_pull.last_message if old_pull else None,
|
|
||||||
last_rows=old_pull.last_rows if old_pull else None,
|
|
||||||
)
|
|
||||||
store.upsert(config)
|
|
||||||
|
|
||||||
# 刷新调度器
|
|
||||||
pull_scheduler.refresh(_data_dir(request))
|
|
||||||
|
|
||||||
# 关闭定时拉取时清理残留的 next_run, 避免前端展示一个永不执行的"下次"
|
|
||||||
if not config.pull.enabled:
|
|
||||||
cleared = store.get(config_id)
|
|
||||||
if cleared and cleared.pull and cleared.pull.next_run:
|
|
||||||
cleared.pull.next_run = None
|
|
||||||
store.upsert(cleared)
|
|
||||||
|
|
||||||
return {"status": "ok", "pull": config.pull.to_dict()}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{config_id}/pull/test")
|
@router.post("/{config_id}/pull/test")
|
||||||
async def test_pull(request: Request, config_id: str):
|
async def test_pull(request: Request, config_id: str):
|
||||||
"""测试拉取:请求外部 API 并返回预览数据,不写入。"""
|
"""测试拉取 — serve 端为只读模式,已禁用。"""
|
||||||
store = _store(request)
|
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
|
||||||
config = store.get(config_id)
|
|
||||||
if not config:
|
|
||||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
|
||||||
if not config.pull or not config.pull.url:
|
|
||||||
raise HTTPException(400, "拉取未配置或 URL 为空")
|
|
||||||
|
|
||||||
# 临时构建一个带新配置的 config 用于测试
|
|
||||||
from app.services.ext_pull import _extract_rows, _apply_field_map
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
pull = config.pull
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
headers = pull.headers or {}
|
|
||||||
kwargs: dict = {"headers": headers}
|
|
||||||
if pull.method.upper() == "POST" and pull.body:
|
|
||||||
kwargs["content"] = pull.body
|
|
||||||
if "content-type" not in {k.lower() for k in headers}:
|
|
||||||
kwargs["headers"]["Content-Type"] = "application/json"
|
|
||||||
resp = await client.request(pull.method.upper(), pull.url, **kwargs)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
rows = _extract_rows(data, pull.response_path)
|
|
||||||
preview = _apply_field_map(rows[:5], pull.field_map)
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"total_rows": len(rows),
|
|
||||||
"preview": preview,
|
|
||||||
"has_symbol": bool(rows and "symbol" in rows[0]),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(400, f"测试失败: {e}") from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{config_id}/pull/run")
|
@router.post("/{config_id}/pull/run")
|
||||||
async def run_pull(request: Request, config_id: str):
|
async def run_pull(request: Request, config_id: str):
|
||||||
"""手动触发一次拉取并写入。"""
|
"""手动触发一次拉取并写入。"""
|
||||||
store = _store(request)
|
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
|
||||||
config = store.get(config_id)
|
|
||||||
if not config:
|
|
||||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
|
||||||
if not config.pull or not config.pull.url:
|
|
||||||
raise HTTPException(400, "拉取未配置或 URL 为空")
|
|
||||||
|
|
||||||
try:
|
|
||||||
n, d = await fetch_and_ingest(config, _data_dir(request))
|
|
||||||
_refresh_views(request)
|
|
||||||
# 写回执行状态, 让前端"上次执行"面板立即反映
|
|
||||||
updated = store.get(config_id)
|
|
||||||
if updated and updated.pull:
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
updated.pull.last_run = datetime.now(timezone.utc).isoformat()
|
|
||||||
updated.pull.last_status = "success"
|
|
||||||
updated.pull.last_message = f"{n} rows @ {d}"
|
|
||||||
updated.pull.last_rows = n
|
|
||||||
store.upsert(updated)
|
|
||||||
return {"status": "ok", "rows": n, "date": d}
|
|
||||||
except Exception as e:
|
|
||||||
# 失败也写回状态, 记录错误信息
|
|
||||||
failed = store.get(config_id)
|
|
||||||
if failed and failed.pull:
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
failed.pull.last_run = datetime.now(timezone.utc).isoformat()
|
|
||||||
failed.pull.last_status = "error"
|
|
||||||
failed.pull.last_message = str(e)[:200]
|
|
||||||
store.upsert(failed)
|
|
||||||
raise HTTPException(400, f"拉取失败: {e}") from e
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -98,27 +98,8 @@ def get_cash_flow(request: Request, symbol: str | None = None):
|
|||||||
|
|
||||||
@router.post("/sync/{table}")
|
@router.post("/sync/{table}")
|
||||||
def sync_table(request: Request, table: str):
|
def sync_table(request: Request, table: str):
|
||||||
"""手动触发同步(立即返回,后台异步执行)。
|
"""手动触发同步 — serve 端为只读模式,已禁用。"""
|
||||||
|
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
|
||||||
table: metrics / income / balance_sheet / cash_flow / all
|
|
||||||
同步在后台线程执行,全量同步需数分钟。本接口立即返回 started 状态,
|
|
||||||
前端通过轮询 GET /status 的 syncing 字段观察进度。
|
|
||||||
"""
|
|
||||||
capset = request.app.state.capabilities
|
|
||||||
capset.require(Cap.FINANCIAL)
|
|
||||||
|
|
||||||
valid_tables = {"metrics", "income", "balance_sheet", "cash_flow", "all"}
|
|
||||||
if table not in valid_tables:
|
|
||||||
raise HTTPException(400, f"invalid table: {table}, expected one of {valid_tables}")
|
|
||||||
|
|
||||||
fs = getattr(request.app.state, "financial_scheduler", None)
|
|
||||||
if not fs:
|
|
||||||
return {"status": "error", "message": "FinancialScheduler not available"}
|
|
||||||
|
|
||||||
target = None if table == "all" else table
|
|
||||||
result = fs.trigger(target)
|
|
||||||
|
|
||||||
return {"status": "ok", "synced": result}
|
|
||||||
|
|
||||||
|
|
||||||
class AnalyzeRequest(BaseModel):
|
class AnalyzeRequest(BaseModel):
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info(
|
logger.info(
|
||||||
"TickFlow Stock Panel v%s starting (mode=%s)",
|
"A股工作台 v%s starting (mode=%s)",
|
||||||
__version__, tf_client.current_mode(),
|
__version__, tf_client.current_mode(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,26 +63,13 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.warning("scheduler not started: %s", e)
|
logger.warning("scheduler not started: %s", e)
|
||||||
app.state.scheduler = None
|
app.state.scheduler = None
|
||||||
|
|
||||||
# 扩展数据定时拉取
|
# 内置扩展表 (概念/行业): 只创建 config 供 sync 解压时用, 不拉数据。
|
||||||
from app.services.ext_pull import pull_scheduler
|
|
||||||
pull_scheduler.start(store.data_dir)
|
|
||||||
pull_scheduler.refresh(store.data_dir)
|
|
||||||
app.state.pull_scheduler = pull_scheduler
|
|
||||||
|
|
||||||
# 内置扩展表 (概念/行业): 只创建 config (含拉取配置), 不自动拉数据
|
|
||||||
# 数据获取由用户在概念/行业页点「获取数据」手动触发 (POST /api/ext-data/presets/{id}/fetch)
|
|
||||||
try:
|
try:
|
||||||
from app.services.ext_presets import ensure_builtin_presets
|
from app.services.ext_presets import ensure_builtin_presets
|
||||||
await ensure_builtin_presets(store.data_dir)
|
await ensure_builtin_presets(store.data_dir)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("内置扩展表初始化失败 (不影响启动): %s", e)
|
logger.warning("内置扩展表初始化失败 (不影响启动): %s", e)
|
||||||
|
|
||||||
# 财务数据 (需 Expert 套餐): 仅初始化调度器供 /api/financials/sync/* 手动同步,
|
|
||||||
# 不启动自动调度——用户在「财务分析」页点「同步」手动拉取。
|
|
||||||
from app.services.financial_sync import financial_scheduler
|
|
||||||
financial_scheduler.start(store.data_dir, capset)
|
|
||||||
app.state.financial_scheduler = financial_scheduler
|
|
||||||
|
|
||||||
# 策略引擎
|
# 策略引擎
|
||||||
from app.strategy.engine import StrategyEngine
|
from app.strategy.engine import StrategyEngine
|
||||||
from app.services.screener import ScreenerService
|
from app.services.screener import ScreenerService
|
||||||
@@ -105,17 +92,11 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
if app.state.scheduler:
|
if app.state.scheduler:
|
||||||
app.state.scheduler.shutdown(wait=False)
|
app.state.scheduler.shutdown(wait=False)
|
||||||
ps = getattr(app.state, "pull_scheduler", None)
|
|
||||||
if ps:
|
|
||||||
ps.stop()
|
|
||||||
fsc = getattr(app.state, "financial_scheduler", None)
|
|
||||||
if fsc:
|
|
||||||
fsc.stop()
|
|
||||||
logger.info("shutdown")
|
logger.info("shutdown")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="TickFlow Stock Panel",
|
title="A股工作台",
|
||||||
version=__version__,
|
version=__version__,
|
||||||
description="A 股选股 + 回测面板 — TickFlow 适配",
|
description="A 股选股 + 回测面板 — TickFlow 适配",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="theme-color" content="#8B5CF6" />
|
<meta name="theme-color" content="#8B5CF6" />
|
||||||
<title>TickFlow Stock Panel · Quant Terminal</title>
|
<title>A股工作台</title>
|
||||||
<link rel="preconnect" href="https://rsms.me/" />
|
<link rel="preconnect" href="https://rsms.me/" />
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
|||||||
@@ -127,10 +127,6 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
|
|
||||||
Quant · Terminal
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="mt-3 h-px"
|
className="mt-3 h-px"
|
||||||
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function Logo({ className, size = 32, style }: LogoProps) {
|
|||||||
className={className}
|
className={className}
|
||||||
style={style}
|
style={style}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="TickFlow Stock Panel"
|
aria-label="A股工作台"
|
||||||
>
|
>
|
||||||
{/* 左方括号 */}
|
{/* 左方括号 */}
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function Auth() {
|
|||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="mb-6 flex flex-col items-center gap-2">
|
<div className="mb-6 flex flex-col items-center gap-2">
|
||||||
<Logo className="h-10 w-10" />
|
<Logo className="h-10 w-10" />
|
||||||
<h1 className="text-lg font-semibold text-foreground">TickFlow Stock Panel</h1>
|
<h1 className="text-lg font-semibold text-foreground">A股工作台</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-card border border-border bg-surface/90 p-6 shadow-2xl backdrop-blur">
|
<div className="rounded-card border border-border bg-surface/90 p-6 shadow-2xl backdrop-blur">
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ interface Variant {
|
|||||||
glow?: string // 名字下方的发光线条 hex
|
glow?: string // 名字下方的发光线条 hex
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同一个名字 "TickFlow Stock Panel" 在 4 种风格语言里的呈现
|
// 同一个名字 "A股工作台" 在 4 种风格语言里的呈现
|
||||||
// 长字符串自动用更小字号 + 更窄字距,免得撑爆卡片;但风格语言(字体/字重/配色/图标)保持不变
|
// 长字符串自动用更小字号 + 更窄字距,免得撑爆卡片;但风格语言(字体/字重/配色/图标)保持不变
|
||||||
const VARIANTS: Variant[] = [
|
const VARIANTS: Variant[] = [
|
||||||
{
|
{
|
||||||
id: 'pulsar',
|
id: 'pulsar',
|
||||||
name: 'TickFlow Stock Panel',
|
name: 'A股工作台',
|
||||||
tagline: 'A-SHARE · SIGNAL TERMINAL',
|
tagline: 'A-SHARE · SIGNAL TERMINAL',
|
||||||
hint: '脉冲星、雷达波纹 — 青绿强调色,字重黑体,中等字距',
|
hint: '脉冲星、雷达波纹 — 青绿强调色,字重黑体,中等字距',
|
||||||
icon: RadioTower,
|
icon: RadioTower,
|
||||||
@@ -40,7 +40,7 @@ const VARIANTS: Variant[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vanta',
|
id: 'vanta',
|
||||||
name: 'TickFlow Stock Panel',
|
name: 'A股工作台',
|
||||||
tagline: 'MARKET · INTELLIGENCE',
|
tagline: 'MARKET · INTELLIGENCE',
|
||||||
hint: 'Vantablack — 纯白单色,字重最重,字距最宽,monochrome 高级感',
|
hint: 'Vantablack — 纯白单色,字重最重,字距最宽,monochrome 高级感',
|
||||||
icon: Square,
|
icon: Square,
|
||||||
@@ -50,7 +50,7 @@ const VARIANTS: Variant[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'helix',
|
id: 'helix',
|
||||||
name: 'TickFlow Stock Panel',
|
name: 'A股工作台',
|
||||||
tagline: 'QUANT · TERMINAL',
|
tagline: 'QUANT · TERMINAL',
|
||||||
hint: 'DNA 螺旋 — 紫色强调,等宽字体,赛博朋克经典意象',
|
hint: 'DNA 螺旋 — 紫色强调,等宽字体,赛博朋克经典意象',
|
||||||
icon: GitFork,
|
icon: GitFork,
|
||||||
@@ -60,7 +60,7 @@ const VARIANTS: Variant[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'aurora',
|
id: 'aurora',
|
||||||
name: 'TickFlow Stock Panel',
|
name: 'A股工作台',
|
||||||
tagline: 'A-SHARE · DASHBOARD',
|
tagline: 'A-SHARE · DASHBOARD',
|
||||||
hint: '极光 — 青色强调,细字优雅,适中字距,与涨跌语义色不冲突',
|
hint: '极光 — 青色强调,细字优雅,适中字距,与涨跌语义色不冲突',
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
@@ -85,7 +85,7 @@ export function Branding() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="视觉风格预览"
|
title="视觉风格预览"
|
||||||
subtitle="名字保持 TickFlow Stock Panel,4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
|
subtitle="名字「A股工作台」的 4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="px-8 py-6">
|
<div className="px-8 py-6">
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function Onboarding() {
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
|
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-semibold tracking-tight">TickFlow Stock Panel</span>
|
<span className="text-sm font-semibold tracking-tight">A股工作台</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 步骤进度条 —— 胶囊式 */}
|
{/* 步骤进度条 —— 胶囊式 */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -179,7 +179,7 @@ function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<h1 className="mt-6 text-3xl font-bold text-foreground tracking-tight">
|
<h1 className="mt-6 text-3xl font-bold text-foreground tracking-tight">
|
||||||
欢迎使用 TickFlow Stock Panel
|
欢迎使用 A股工作台
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-sm text-secondary leading-relaxed max-w-md mx-auto">
|
<p className="mt-3 text-sm text-secondary leading-relaxed max-w-md mx-auto">
|
||||||
一个本地化的 A 股量化分析面板 —— 行情、选股、回测、监控、财务一体化。
|
一个本地化的 A 股量化分析面板 —— 行情、选股、回测、监控、财务一体化。
|
||||||
|
|||||||
Reference in New Issue
Block a user