From e09ce715d3ed651e5507efbe271ae0b5b2fcb7ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:35:32 +0000 Subject: [PATCH 01/11] Add market API fallback and redesign dashboard layout --- index.html | 132 +++++++++++++++++++++++++++++--------------- portfolio_server.py | 62 +++++++++++++++++---- 2 files changed, 140 insertions(+), 54 deletions(-) diff --git a/index.html b/index.html index 4113ef3..27f5d73 100644 --- a/index.html +++ b/index.html @@ -23,13 +23,7 @@ color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } - .page { - max-width: 1320px; - margin: 0 auto; - padding: 12px; - display: grid; - gap: 12px; - } + .page { width: 100%; padding: 10px 14px; display: grid; gap: 12px; } .card { background: var(--card); border: 1px solid var(--line); @@ -47,11 +41,13 @@ h1 { font-size: 20px; } h2 { font-size: 16px; margin-bottom: 8px; } .muted { color: var(--muted); font-size: 13px; } - .grid-2 { + .workspace { display: grid; gap: 12px; - grid-template-columns: 2fr 1fr; + grid-template-columns: minmax(300px, 1.2fr) minmax(480px, 2fr) minmax(260px, 1fr); + align-items: start; } + .stack { display: grid; gap: 12px; } textarea { width: 100%; min-height: 120px; @@ -91,16 +87,37 @@ .assets { display: grid; gap: 10px; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-template-columns: 1fr; } .asset-item { border: 1px solid var(--line); border-radius: 8px; - padding: 10px; + padding: 8px; background: #fff; } - .chart { width: 100%; height: 340px; } - .asset-chart { width: 100%; height: 250px; margin-top: 8px; } + .chart { width: 100%; height: 280px; } + .asset-chart { width: 100%; height: 170px; margin-top: 6px; } + .side-list { + display: grid; + gap: 8px; + max-height: 560px; + overflow: auto; + padding-right: 4px; + } + .side-item { + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfcff; + padding: 8px; + } + .side-item .line { + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 4px; + font-size: 13px; + } + .price-value { font-size: 20px; font-weight: 700; text-align: right; } .status-dot { width: 10px; height: 10px; @@ -112,9 +129,10 @@ .status-dot.err { background: #cc3f3f; } @media (max-width: 860px) { - .grid-2 { grid-template-columns: 1fr; } + .workspace { grid-template-columns: 1fr; } .chart { height: 290px; } .asset-chart { height: 220px; } + .side-list { max-height: none; } } @@ -133,38 +151,50 @@

FinHelper 实时资产看板

-
-
-

导入持仓(名称或代码)

-

每行:名称/代码, 数量, 成本价。示例已包含现货黄金测试用例。

- -
- - - +
+
+
+

持仓信息(左侧)

+
+
+
+

导入持仓(名称或代码)

+

每行:名称/代码, 数量, 成本价。示例已包含现货黄金测试用例。

+ +
+ + + +
+

-

-
-

客户交互(预留)

-

加载中...

-
- +
+
+

总资产实时指标

+
+
+
+
+

各资产曲线

+
-

后续可接入登录、消息、指令回传,此面板独立于图表区,避免冲突。

-
-
-

总资产实时指标

-
-
+
+

资产价格(右侧)

+
+
-

各资产实时指标与曲线

-
+

客户交互(预留,底部)

+

加载中...

+
+ +
+

后续可接入登录、消息、指令回传,此面板独立于图表区,避免冲突。

@@ -175,6 +205,8 @@

各资产实时指标与曲线

const importResult = document.getElementById('importResult'); const portfolioMetrics = document.getElementById('portfolioMetrics'); const assetsContainer = document.getElementById('assetsContainer'); + const holdingsBoard = document.getElementById('holdingsBoard'); + const priceBoard = document.getElementById('priceBoard'); const holdingsInput = document.getElementById('holdingsInput'); const DEFAULT_GOLD_CASE = '现货黄金,1,2300'; @@ -237,6 +269,25 @@

各资产实时指标与曲线

showlegend: false }, { responsive: true, displaylogo: false }); + holdingsBoard.innerHTML = data.assets.map(asset => ` +
+

${asset.display_name} (${asset.symbol})

+
持仓数量${asset.quantity}
+
成本价${fmtMoney(asset.cost_price)}
+
持仓市值${fmtMoney(asset.current_value)}
+
持仓收益${fmtMoney(asset.since_profit)}
+
+ `).join('') || '

暂无持仓数据

'; + + priceBoard.innerHTML = data.assets.map(asset => ` +
+

${asset.display_name}

+
${fmtMoney(asset.current_price)}
+
当日盈亏${fmtMoney(asset.today_profit)}
+
持仓收益率${fmtPct(asset.since_return_rate)}
+
+ `).join('') || '

暂无价格数据

'; + assetsContainer.innerHTML = ''; data.assets.forEach((asset, idx) => { const id = `assetChart_${idx}`; @@ -244,12 +295,7 @@

各资产实时指标与曲线

wrapper.className = 'asset-item'; wrapper.innerHTML = `

${asset.display_name} (${asset.symbol})

-

数量: ${asset.quantity} | 成本价: ${fmtMoney(asset.cost_price)} | 最新价: ${fmtMoney(asset.current_price)}

-
-
持仓收益
${fmtMoney(asset.since_profit)}
-
收益率
${fmtPct(asset.since_return_rate)}
-
当日收益
${fmtMoney(asset.today_profit)}
-
+

持仓收益: ${fmtMoney(asset.since_profit)} | 当日收益: ${fmtMoney(asset.today_profit)}

`; assetsContainer.appendChild(wrapper); diff --git a/portfolio_server.py b/portfolio_server.py index d7cb458..978183b 100644 --- a/portfolio_server.py +++ b/portfolio_server.py @@ -20,6 +20,11 @@ DEFAULT_HEADERS = {"User-Agent": "FinHelper/1.0"} MIN_POLL_INTERVAL = 5 DEFAULT_GOLD_TEST_COST = 2300.0 +YAHOO_CHART_ENDPOINTS = ( + "https://query1.finance.yahoo.com/v8/finance/chart/{encoded}?interval=1m&range=1d", + "https://query2.finance.yahoo.com/v8/finance/chart/{encoded}?interval=1m&range=1d", +) +YAHOO_QUOTE_ENDPOINT = "https://query2.finance.yahoo.com/v7/finance/quote?symbols={encoded}" SYMBOL_ALIASES = { @@ -115,23 +120,17 @@ def broadcast_snapshot(self) -> None: for sub in stale: self.unregister_subscriber(sub) - def fetch_curve(self, symbol: str) -> Dict[str, Any]: - encoded = urllib.parse.quote(symbol) - url = f"https://query1.finance.yahoo.com/v8/finance/chart/{encoded}?interval=1m&range=1d" + def _request_json(self, url: str) -> Dict[str, Any]: req = urllib.request.Request(url, headers=DEFAULT_HEADERS) try: with urllib.request.urlopen(req, timeout=8) as resp: body = resp.read() except urllib.error.URLError as exc: - raise ValueError(f"{symbol} 行情获取失败: 网络连接异常") from exc - parsed = json.loads(body.decode("utf-8")) - chart = parsed.get("chart", {}) - if chart.get("error"): - raise ValueError(str(chart["error"])) - result = (chart.get("result") or [None])[0] - if not result: - raise ValueError("No chart result") + raise ValueError("网络连接异常") from exc + return json.loads(body.decode("utf-8")) + @staticmethod + def _build_points(result: Dict[str, Any]) -> List[Dict[str, float]]: timestamps = result.get("timestamp") or [] quote = ((result.get("indicators") or {}).get("quote") or [{}])[0] closes = quote.get("close") or [] @@ -143,7 +142,18 @@ def fetch_curve(self, symbol: str) -> Dict[str, Any]: if px is None: continue points.append({"ts": int(ts), "price": float(px)}) + return points + + def _fetch_curve_from_chart(self, url: str) -> Dict[str, Any]: + parsed = self._request_json(url) + chart = parsed.get("chart", {}) + if chart.get("error"): + raise ValueError(str(chart["error"])) + result = (chart.get("result") or [None])[0] + if not result: + raise ValueError("No chart result") + points = self._build_points(result) meta = result.get("meta") or {} current = float(meta.get("regularMarketPrice") or (points[-1]["price"] if points else 0.0)) day_open = points[0]["price"] if points else current @@ -151,6 +161,36 @@ def fetch_curve(self, symbol: str) -> Dict[str, Any]: raise ValueError("No valid market price") return {"current": current, "day_open": day_open, "points": points} + def _fetch_curve_from_quote(self, encoded: str) -> Dict[str, Any]: + parsed = self._request_json(YAHOO_QUOTE_ENDPOINT.format(encoded=encoded)) + result = ((parsed.get("quoteResponse") or {}).get("result") or [None])[0] + if not result: + raise ValueError("No quote result") + current = float(result.get("regularMarketPrice") or 0.0) + day_open = float(result.get("regularMarketOpen") or current) + if current <= 0: + raise ValueError("No valid quote price") + return {"current": current, "day_open": day_open, "points": []} + + def fetch_curve(self, symbol: str) -> Dict[str, Any]: + encoded = urllib.parse.quote(symbol) + errors: List[str] = [] + + for pattern in YAHOO_CHART_ENDPOINTS: + url = pattern.format(encoded=encoded) + try: + return self._fetch_curve_from_chart(url) + except Exception as exc: + errors.append(str(exc)) + + try: + return self._fetch_curve_from_quote(encoded) + except Exception as exc: + errors.append(str(exc)) + + detail = " | ".join(filter(None, errors[-3:])) + raise ValueError(f"{symbol} 行情获取失败: {detail or '网络连接异常'}") + def update_market_data(self) -> None: with self._lock: symbols = list(self.holdings.keys()) From 03789f35f87933a4d36a4500acd2d973b4d9c5d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:45:55 +0000 Subject: [PATCH 02/11] Add domestic market fallback and CI/CD workflow --- .github/workflows/portfolio-ci-cd.yml | 70 ++++++++++++++++ portfolio_server.py | 113 +++++++++++++++++++++++++- 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/portfolio-ci-cd.yml diff --git a/.github/workflows/portfolio-ci-cd.yml b/.github/workflows/portfolio-ci-cd.yml new file mode 100644 index 0000000..eb0f1d0 --- /dev/null +++ b/.github/workflows/portfolio-ci-cd.yml @@ -0,0 +1,70 @@ +name: portfolio-ci-cd + +on: + push: + branches: [main] + workflow_dispatch: + +env: + TARGET_HOST: 120.55.1.225 + TARGET_PORT: 22 + DEPLOY_PATH: /opt/finhelper + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + + - name: Setup Python + uses: actions/setup-python@v5.1.1 + with: + python-version: "3.11" + + - name: Syntax check + run: | + python -m py_compile portfolio_server.py get_gold_prices.py ui_train_server.py prep_script.py + + deploy: + runs-on: ubuntu-latest + needs: ci + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + + - name: Prepare SSH + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + test -n "$DEPLOY_SSH_KEY" + mkdir -p ~/.ssh + printf "%s" "$DEPLOY_SSH_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -p "$TARGET_PORT" -H "$TARGET_HOST" >> ~/.ssh/known_hosts + + - name: Sync files to server + env: + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + run: | + test -n "$DEPLOY_USER" + rsync -az --delete \ + --exclude '.git' \ + --exclude '.github' \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + -e "ssh -p $TARGET_PORT" \ + ./ "$DEPLOY_USER@$TARGET_HOST:$DEPLOY_PATH/" + + - name: Restart portfolio service + env: + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + run: | + ssh -p "$TARGET_PORT" "$DEPLOY_USER@$TARGET_HOST" <<'EOF' + set -e + cd /opt/finhelper + python3 -m py_compile portfolio_server.py + pkill -f "python3 portfolio_server.py" || true + nohup python3 portfolio_server.py --host 0.0.0.0 --port 8877 > portfolio_server.log 2>&1 & + EOF diff --git a/portfolio_server.py b/portfolio_server.py index 978183b..a7ba75d 100644 --- a/portfolio_server.py +++ b/portfolio_server.py @@ -4,6 +4,7 @@ import json import logging import queue +import re import threading import time import urllib.error @@ -14,6 +15,11 @@ from pathlib import Path from typing import Any, Dict, List, Optional +try: + import akshare as ak # type: ignore +except Exception: # pragma: no cover - optional dependency + ak = None + ROOT = Path(__file__).resolve().parent STATIC_INDEX = ROOT / "index.html" @@ -25,6 +31,16 @@ "https://query2.finance.yahoo.com/v8/finance/chart/{encoded}?interval=1m&range=1d", ) YAHOO_QUOTE_ENDPOINT = "https://query2.finance.yahoo.com/v7/finance/quote?symbols={encoded}" +TENCENT_QUOTE_ENDPOINT = "https://qt.gtimg.cn/q={code}" +SINA_QUOTE_ENDPOINT = "https://hq.sinajs.cn/list={code}" +MAX_ERROR_MESSAGES = 4 +DOMESTIC_SYMBOLS = { + "XAUUSD=X": { + "tencent": ["hf_XAU", "hf_GC"], + "sina": ["hf_XAU", "hf_GC"], + "akshare": ["AU9999", "GC"], + } +} SYMBOL_ALIASES = { @@ -144,6 +160,27 @@ def _build_points(result: Dict[str, Any]) -> List[Dict[str, float]]: points.append({"ts": int(ts), "price": float(px)}) return points + @staticmethod + def _extract_reliable_prices(raw: str) -> Dict[str, float]: + values = [] + for token in re.split(r"[,\s~\"]+", raw): + token = token.strip() + if not token: + continue + try: + values.append(float(token)) + except ValueError: + continue + preferred = [v for v in values if 100 <= v <= 10000] + candidates = preferred or [v for v in values if 0 < v < 1000000] + if not candidates: + raise ValueError("No numeric price in payload") + current = candidates[0] + day_open = candidates[1] if len(candidates) > 1 else current + if current <= 0: + raise ValueError("No valid domestic quote price") + return {"current": float(current), "day_open": float(day_open), "points": []} + def _fetch_curve_from_chart(self, url: str) -> Dict[str, Any]: parsed = self._request_json(url) chart = parsed.get("chart", {}) @@ -172,6 +209,75 @@ def _fetch_curve_from_quote(self, encoded: str) -> Dict[str, Any]: raise ValueError("No valid quote price") return {"current": current, "day_open": day_open, "points": []} + def _fetch_curve_from_tencent(self, code: str) -> Dict[str, Any]: + req = urllib.request.Request(TENCENT_QUOTE_ENDPOINT.format(code=code), headers=DEFAULT_HEADERS) + with urllib.request.urlopen(req, timeout=8) as resp: + raw = resp.read().decode("utf-8", errors="ignore") + return self._extract_reliable_prices(raw) + + def _fetch_curve_from_sina(self, code: str) -> Dict[str, Any]: + req = urllib.request.Request(SINA_QUOTE_ENDPOINT.format(code=code), headers=DEFAULT_HEADERS) + with urllib.request.urlopen(req, timeout=8) as resp: + raw = resp.read().decode("gbk", errors="ignore") + return self._extract_reliable_prices(raw) + + def _fetch_curve_from_akshare(self, symbol: str) -> Dict[str, Any]: + if ak is None: + raise ValueError("akshare unavailable") + + if hasattr(ak, "spot_hist_sge") and symbol.upper() in {"AU9999", "AU99.99"}: + df = ak.spot_hist_sge(symbol="Au99.99") + if df is not None and not df.empty: + row = df.iloc[-1].to_dict() + close = ( + row.get("收盘") + or row.get("close") + or row.get("最新价") + or row.get("price") + or row.get("价格") + ) + if close is not None: + current = float(close) + return {"current": current, "day_open": current, "points": []} + + if hasattr(ak, "futures_foreign_hist"): + df = ak.futures_foreign_hist(symbol=symbol) + if df is not None and not df.empty: + row = df.iloc[-1].to_dict() + close = row.get("close") or row.get("收盘") or row.get("最新价") + open_px = row.get("open") or row.get("开盘") or close + if close is not None: + return {"current": float(close), "day_open": float(open_px), "points": []} + + raise ValueError("akshare no usable quote") + + def _fetch_curve_from_domestic(self, symbol: str) -> Dict[str, Any]: + conf = DOMESTIC_SYMBOLS.get(symbol) + if not conf: + raise ValueError("No domestic mapping") + errors: List[str] = [] + + for code in conf.get("tencent", []): + try: + return self._fetch_curve_from_tencent(code) + except Exception as exc: + errors.append(f"tencent:{exc}") + + for code in conf.get("sina", []): + try: + return self._fetch_curve_from_sina(code) + except Exception as exc: + errors.append(f"sina:{exc}") + + for ak_symbol in conf.get("akshare", []): + try: + return self._fetch_curve_from_akshare(ak_symbol) + except Exception as exc: + errors.append(f"akshare:{exc}") + + detail = " | ".join(filter(None, errors[-MAX_ERROR_MESSAGES:])) + raise ValueError(detail or "domestic quote failed") + def fetch_curve(self, symbol: str) -> Dict[str, Any]: encoded = urllib.parse.quote(symbol) errors: List[str] = [] @@ -188,7 +294,12 @@ def fetch_curve(self, symbol: str) -> Dict[str, Any]: except Exception as exc: errors.append(str(exc)) - detail = " | ".join(filter(None, errors[-3:])) + try: + return self._fetch_curve_from_domestic(symbol) + except Exception as exc: + errors.append(str(exc)) + + detail = " | ".join(filter(None, errors[-MAX_ERROR_MESSAGES:])) raise ValueError(f"{symbol} 行情获取失败: {detail or '网络连接异常'}") def update_market_data(self) -> None: From 9ba5caa64f70ade963936ebde021d69e9e19551d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:47:13 +0000 Subject: [PATCH 03/11] Harden workflow permissions and restart flow --- .github/workflows/portfolio-ci-cd.yml | 9 +++++++++ portfolio_server.py | 11 +++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/portfolio-ci-cd.yml b/.github/workflows/portfolio-ci-cd.yml index eb0f1d0..27892a4 100644 --- a/.github/workflows/portfolio-ci-cd.yml +++ b/.github/workflows/portfolio-ci-cd.yml @@ -5,6 +5,9 @@ on: branches: [main] workflow_dispatch: +permissions: + contents: read + env: TARGET_HOST: 120.55.1.225 TARGET_PORT: 22 @@ -66,5 +69,11 @@ jobs: cd /opt/finhelper python3 -m py_compile portfolio_server.py pkill -f "python3 portfolio_server.py" || true + for i in 1 2 3 4 5; do + if ! pgrep -f "python3 portfolio_server.py" >/dev/null; then + break + fi + sleep 1 + done nohup python3 portfolio_server.py --host 0.0.0.0 --port 8877 > portfolio_server.log 2>&1 & EOF diff --git a/portfolio_server.py b/portfolio_server.py index a7ba75d..251191b 100644 --- a/portfolio_server.py +++ b/portfolio_server.py @@ -34,6 +34,9 @@ TENCENT_QUOTE_ENDPOINT = "https://qt.gtimg.cn/q={code}" SINA_QUOTE_ENDPOINT = "https://hq.sinajs.cn/list={code}" MAX_ERROR_MESSAGES = 4 +PREFERRED_PRICE_MIN = 100.0 +PREFERRED_PRICE_MAX = 10000.0 +FALLBACK_PRICE_MAX = 1000000.0 DOMESTIC_SYMBOLS = { "XAUUSD=X": { "tencent": ["hf_XAU", "hf_GC"], @@ -171,8 +174,8 @@ def _extract_reliable_prices(raw: str) -> Dict[str, float]: values.append(float(token)) except ValueError: continue - preferred = [v for v in values if 100 <= v <= 10000] - candidates = preferred or [v for v in values if 0 < v < 1000000] + preferred = [v for v in values if PREFERRED_PRICE_MIN <= v <= PREFERRED_PRICE_MAX] + candidates = preferred or [v for v in values if 0 < v < FALLBACK_PRICE_MAX] if not candidates: raise ValueError("No numeric price in payload") current = candidates[0] @@ -275,7 +278,7 @@ def _fetch_curve_from_domestic(self, symbol: str) -> Dict[str, Any]: except Exception as exc: errors.append(f"akshare:{exc}") - detail = " | ".join(filter(None, errors[-MAX_ERROR_MESSAGES:])) + detail = " | ".join(filter(None, errors[-MAX_ERROR_MESSAGES:] if len(errors) > MAX_ERROR_MESSAGES else errors)) raise ValueError(detail or "domestic quote failed") def fetch_curve(self, symbol: str) -> Dict[str, Any]: @@ -299,7 +302,7 @@ def fetch_curve(self, symbol: str) -> Dict[str, Any]: except Exception as exc: errors.append(str(exc)) - detail = " | ".join(filter(None, errors[-MAX_ERROR_MESSAGES:])) + detail = " | ".join(filter(None, errors[-MAX_ERROR_MESSAGES:] if len(errors) > MAX_ERROR_MESSAGES else errors)) raise ValueError(f"{symbol} 行情获取失败: {detail or '网络连接异常'}") def update_market_data(self) -> None: From b05b3c17f42f2ed0ccc8a00255ba88667f80db6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:48:19 +0000 Subject: [PATCH 04/11] Refine domestic fallback error handling --- portfolio_server.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/portfolio_server.py b/portfolio_server.py index 251191b..bd71708 100644 --- a/portfolio_server.py +++ b/portfolio_server.py @@ -180,10 +180,12 @@ def _extract_reliable_prices(raw: str) -> Dict[str, float]: raise ValueError("No numeric price in payload") current = candidates[0] day_open = candidates[1] if len(candidates) > 1 else current - if current <= 0: - raise ValueError("No valid domestic quote price") return {"current": float(current), "day_open": float(day_open), "points": []} + @staticmethod + def _tail_errors(errors: List[str]) -> List[str]: + return errors[-MAX_ERROR_MESSAGES:] if len(errors) > MAX_ERROR_MESSAGES else errors + def _fetch_curve_from_chart(self, url: str) -> Dict[str, Any]: parsed = self._request_json(url) chart = parsed.get("chart", {}) @@ -278,7 +280,7 @@ def _fetch_curve_from_domestic(self, symbol: str) -> Dict[str, Any]: except Exception as exc: errors.append(f"akshare:{exc}") - detail = " | ".join(filter(None, errors[-MAX_ERROR_MESSAGES:] if len(errors) > MAX_ERROR_MESSAGES else errors)) + detail = " | ".join(filter(None, self._tail_errors(errors))) raise ValueError(detail or "domestic quote failed") def fetch_curve(self, symbol: str) -> Dict[str, Any]: @@ -302,7 +304,7 @@ def fetch_curve(self, symbol: str) -> Dict[str, Any]: except Exception as exc: errors.append(str(exc)) - detail = " | ".join(filter(None, errors[-MAX_ERROR_MESSAGES:] if len(errors) > MAX_ERROR_MESSAGES else errors)) + detail = " | ".join(filter(None, self._tail_errors(errors))) raise ValueError(f"{symbol} 行情获取失败: {detail or '网络连接异常'}") def update_market_data(self) -> None: From 07a0a8ccebd8111c6816b4ba824aed6cb0d7307c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:49:15 +0000 Subject: [PATCH 05/11] Improve quote parsing and deploy process matching --- .github/workflows/portfolio-ci-cd.yml | 4 ++-- portfolio_server.py | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/portfolio-ci-cd.yml b/.github/workflows/portfolio-ci-cd.yml index 27892a4..45559f4 100644 --- a/.github/workflows/portfolio-ci-cd.yml +++ b/.github/workflows/portfolio-ci-cd.yml @@ -68,9 +68,9 @@ jobs: set -e cd /opt/finhelper python3 -m py_compile portfolio_server.py - pkill -f "python3 portfolio_server.py" || true + pkill -f "^python3 .*portfolio_server.py( |$)" || true for i in 1 2 3 4 5; do - if ! pgrep -f "python3 portfolio_server.py" >/dev/null; then + if ! pgrep -f "^python3 .*portfolio_server.py( |$)" >/dev/null; then break fi sleep 1 diff --git a/portfolio_server.py b/portfolio_server.py index bd71708..a59967d 100644 --- a/portfolio_server.py +++ b/portfolio_server.py @@ -166,6 +166,7 @@ def _build_points(result: Dict[str, Any]) -> List[Dict[str, float]]: @staticmethod def _extract_reliable_prices(raw: str) -> Dict[str, float]: values = [] + # Tencent 行情串常用 "~" 分隔,Sina 常用 "," 分隔,这里统一拆分。 for token in re.split(r"[,\s~\"]+", raw): token = token.strip() if not token: @@ -217,20 +218,22 @@ def _fetch_curve_from_quote(self, encoded: str) -> Dict[str, Any]: def _fetch_curve_from_tencent(self, code: str) -> Dict[str, Any]: req = urllib.request.Request(TENCENT_QUOTE_ENDPOINT.format(code=code), headers=DEFAULT_HEADERS) with urllib.request.urlopen(req, timeout=8) as resp: - raw = resp.read().decode("utf-8", errors="ignore") + raw = resp.read().decode("utf-8", errors="replace") return self._extract_reliable_prices(raw) def _fetch_curve_from_sina(self, code: str) -> Dict[str, Any]: req = urllib.request.Request(SINA_QUOTE_ENDPOINT.format(code=code), headers=DEFAULT_HEADERS) with urllib.request.urlopen(req, timeout=8) as resp: - raw = resp.read().decode("gbk", errors="ignore") + raw = resp.read().decode("gbk", errors="replace") return self._extract_reliable_prices(raw) def _fetch_curve_from_akshare(self, symbol: str) -> Dict[str, Any]: if ak is None: raise ValueError("akshare unavailable") - if hasattr(ak, "spot_hist_sge") and symbol.upper() in {"AU9999", "AU99.99"}: + normalized = symbol.upper().replace(".", "") + + if hasattr(ak, "spot_hist_sge") and normalized == "AU9999": df = ak.spot_hist_sge(symbol="Au99.99") if df is not None and not df.empty: row = df.iloc[-1].to_dict() From cf36ae513b90429d460193997fd2976798a6c09b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:50:22 +0000 Subject: [PATCH 06/11] Tighten exception handling and deploy health check --- .github/workflows/portfolio-ci-cd.yml | 2 ++ portfolio_server.py | 24 ++++++++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/portfolio-ci-cd.yml b/.github/workflows/portfolio-ci-cd.yml index 45559f4..464423f 100644 --- a/.github/workflows/portfolio-ci-cd.yml +++ b/.github/workflows/portfolio-ci-cd.yml @@ -76,4 +76,6 @@ jobs: sleep 1 done nohup python3 portfolio_server.py --host 0.0.0.0 --port 8877 > portfolio_server.log 2>&1 & + sleep 2 + pgrep -f "^python3 .*portfolio_server.py( |$)" >/dev/null EOF diff --git a/portfolio_server.py b/portfolio_server.py index a59967d..92f2fab 100644 --- a/portfolio_server.py +++ b/portfolio_server.py @@ -37,6 +37,14 @@ PREFERRED_PRICE_MIN = 100.0 PREFERRED_PRICE_MAX = 10000.0 FALLBACK_PRICE_MAX = 1000000.0 +FETCH_EXCEPTIONS = ( + ValueError, + urllib.error.URLError, + urllib.error.HTTPError, + json.JSONDecodeError, + KeyError, + TypeError, +) DOMESTIC_SYMBOLS = { "XAUUSD=X": { "tencent": ["hf_XAU", "hf_GC"], @@ -164,7 +172,7 @@ def _build_points(result: Dict[str, Any]) -> List[Dict[str, float]]: return points @staticmethod - def _extract_reliable_prices(raw: str) -> Dict[str, float]: + def _extract_reliable_prices(raw: str) -> Dict[str, Any]: values = [] # Tencent 行情串常用 "~" 分隔,Sina 常用 "," 分隔,这里统一拆分。 for token in re.split(r"[,\s~\"]+", raw): @@ -181,7 +189,7 @@ def _extract_reliable_prices(raw: str) -> Dict[str, float]: raise ValueError("No numeric price in payload") current = candidates[0] day_open = candidates[1] if len(candidates) > 1 else current - return {"current": float(current), "day_open": float(day_open), "points": []} + return {"current": current, "day_open": day_open, "points": []} @staticmethod def _tail_errors(errors: List[str]) -> List[str]: @@ -268,19 +276,19 @@ def _fetch_curve_from_domestic(self, symbol: str) -> Dict[str, Any]: for code in conf.get("tencent", []): try: return self._fetch_curve_from_tencent(code) - except Exception as exc: + except FETCH_EXCEPTIONS as exc: errors.append(f"tencent:{exc}") for code in conf.get("sina", []): try: return self._fetch_curve_from_sina(code) - except Exception as exc: + except FETCH_EXCEPTIONS as exc: errors.append(f"sina:{exc}") for ak_symbol in conf.get("akshare", []): try: return self._fetch_curve_from_akshare(ak_symbol) - except Exception as exc: + except FETCH_EXCEPTIONS as exc: errors.append(f"akshare:{exc}") detail = " | ".join(filter(None, self._tail_errors(errors))) @@ -294,17 +302,17 @@ def fetch_curve(self, symbol: str) -> Dict[str, Any]: url = pattern.format(encoded=encoded) try: return self._fetch_curve_from_chart(url) - except Exception as exc: + except FETCH_EXCEPTIONS as exc: errors.append(str(exc)) try: return self._fetch_curve_from_quote(encoded) - except Exception as exc: + except FETCH_EXCEPTIONS as exc: errors.append(str(exc)) try: return self._fetch_curve_from_domestic(symbol) - except Exception as exc: + except FETCH_EXCEPTIONS as exc: errors.append(str(exc)) detail = " | ".join(filter(None, self._tail_errors(errors))) From d3817f2d022e45da0ba940321ea9b2a6e3d64118 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:06:31 +0000 Subject: [PATCH 07/11] Make deploy target env configurable via repo vars --- .github/workflows/portfolio-ci-cd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/portfolio-ci-cd.yml b/.github/workflows/portfolio-ci-cd.yml index 464423f..420360f 100644 --- a/.github/workflows/portfolio-ci-cd.yml +++ b/.github/workflows/portfolio-ci-cd.yml @@ -9,9 +9,9 @@ permissions: contents: read env: - TARGET_HOST: 120.55.1.225 - TARGET_PORT: 22 - DEPLOY_PATH: /opt/finhelper + TARGET_HOST: ${{ vars.TARGET_HOST || '120.55.1.225' }} + TARGET_PORT: ${{ vars.TARGET_PORT || '22' }} + DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/finhelper' }} jobs: ci: From c15672698865cbd860773fec095602e7c5d60418 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:07:47 +0000 Subject: [PATCH 08/11] Use configurable deploy path in remote restart step --- .github/workflows/portfolio-ci-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/portfolio-ci-cd.yml b/.github/workflows/portfolio-ci-cd.yml index 420360f..882cfdd 100644 --- a/.github/workflows/portfolio-ci-cd.yml +++ b/.github/workflows/portfolio-ci-cd.yml @@ -66,7 +66,7 @@ jobs: run: | ssh -p "$TARGET_PORT" "$DEPLOY_USER@$TARGET_HOST" <<'EOF' set -e - cd /opt/finhelper + cd "$DEPLOY_PATH" python3 -m py_compile portfolio_server.py pkill -f "^python3 .*portfolio_server.py( |$)" || true for i in 1 2 3 4 5; do From b136872f5b9e046cca124a07f32a34d1ef6d1d3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:08:27 +0000 Subject: [PATCH 09/11] Pass DEPLOY_PATH into remote SSH restart session --- .github/workflows/portfolio-ci-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/portfolio-ci-cd.yml b/.github/workflows/portfolio-ci-cd.yml index 882cfdd..48d40c4 100644 --- a/.github/workflows/portfolio-ci-cd.yml +++ b/.github/workflows/portfolio-ci-cd.yml @@ -64,7 +64,7 @@ jobs: env: DEPLOY_USER: ${{ secrets.DEPLOY_USER }} run: | - ssh -p "$TARGET_PORT" "$DEPLOY_USER@$TARGET_HOST" <<'EOF' + ssh -p "$TARGET_PORT" "$DEPLOY_USER@$TARGET_HOST" "DEPLOY_PATH='$DEPLOY_PATH' bash -s" <<'EOF' set -e cd "$DEPLOY_PATH" python3 -m py_compile portfolio_server.py From 0756afae1ea8e0e13c37b7b0b9cb66ed3fc1391a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:09:19 +0000 Subject: [PATCH 10/11] Validate DEPLOY_PATH before remote deploy commands --- .github/workflows/portfolio-ci-cd.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/portfolio-ci-cd.yml b/.github/workflows/portfolio-ci-cd.yml index 48d40c4..14c919b 100644 --- a/.github/workflows/portfolio-ci-cd.yml +++ b/.github/workflows/portfolio-ci-cd.yml @@ -52,6 +52,13 @@ jobs: DEPLOY_USER: ${{ secrets.DEPLOY_USER }} run: | test -n "$DEPLOY_USER" + case "$DEPLOY_PATH" in + /*) ;; + *) echo "DEPLOY_PATH must be an absolute path"; exit 1 ;; + esac + case "$DEPLOY_PATH" in + *[!A-Za-z0-9._/-]* ) echo "DEPLOY_PATH contains unsupported characters"; exit 1 ;; + esac rsync -az --delete \ --exclude '.git' \ --exclude '.github' \ @@ -64,6 +71,13 @@ jobs: env: DEPLOY_USER: ${{ secrets.DEPLOY_USER }} run: | + case "$DEPLOY_PATH" in + /*) ;; + *) echo "DEPLOY_PATH must be an absolute path"; exit 1 ;; + esac + case "$DEPLOY_PATH" in + *[!A-Za-z0-9._/-]* ) echo "DEPLOY_PATH contains unsupported characters"; exit 1 ;; + esac ssh -p "$TARGET_PORT" "$DEPLOY_USER@$TARGET_HOST" "DEPLOY_PATH='$DEPLOY_PATH' bash -s" <<'EOF' set -e cd "$DEPLOY_PATH" From 6c547cfa8600ab8e2e7d1fff997d0f681f6f51be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:41:52 +0000 Subject: [PATCH 11/11] Remove hardcoded default deploy host from workflow --- .github/workflows/portfolio-ci-cd.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/portfolio-ci-cd.yml b/.github/workflows/portfolio-ci-cd.yml index 14c919b..21fe9cb 100644 --- a/.github/workflows/portfolio-ci-cd.yml +++ b/.github/workflows/portfolio-ci-cd.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - TARGET_HOST: ${{ vars.TARGET_HOST || '120.55.1.225' }} + TARGET_HOST: ${{ vars.TARGET_HOST }} TARGET_PORT: ${{ vars.TARGET_PORT || '22' }} DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/finhelper' }} @@ -41,6 +41,7 @@ jobs: env: DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} run: | + test -n "$TARGET_HOST" test -n "$DEPLOY_SSH_KEY" mkdir -p ~/.ssh printf "%s" "$DEPLOY_SSH_KEY" > ~/.ssh/id_rsa