Skip to content

Commit 375eca0

Browse files
authored
Fix agent fallback, portfolio pricing, and task queue tests (#1144)
1 parent 07b4fe5 commit 375eca0

12 files changed

Lines changed: 395 additions & 28 deletions

File tree

api/v1/schemas/portfolio.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ class PortfolioPositionItem(BaseModel):
160160
market_value_base: float
161161
unrealized_pnl_base: float
162162
valuation_currency: str
163+
price_source: str = "unknown"
164+
price_provider: Optional[str] = None
165+
price_date: Optional[str] = None
166+
price_stale: bool = False
167+
price_available: bool = True
163168

164169

165170
class PortfolioAccountSnapshot(BaseModel):

apps/dsa-web/src/pages/PortfolioPage.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,31 @@ function formatPct(value: number | undefined | null): string {
8080
return `${value.toFixed(2)}%`;
8181
}
8282

83+
function hasPositionPrice(row: PortfolioPositionItem): boolean {
84+
return row.priceAvailable !== false && row.priceSource !== 'missing';
85+
}
86+
87+
function formatPositionPrice(row: PortfolioPositionItem): string {
88+
if (!hasPositionPrice(row)) return '--';
89+
return row.lastPrice.toFixed(4);
90+
}
91+
92+
function formatPositionMoney(value: number, row: PortfolioPositionItem): string {
93+
if (!hasPositionPrice(row)) return '--';
94+
return formatMoney(value, row.valuationCurrency);
95+
}
96+
97+
function getPositionPriceLabel(row: PortfolioPositionItem): string {
98+
if (!hasPositionPrice(row)) return '缺价';
99+
if (row.priceSource === 'realtime_quote') {
100+
return row.priceProvider ? `实时价 · ${row.priceProvider}` : '实时价';
101+
}
102+
if (row.priceSource === 'history_close') {
103+
return row.priceStale && row.priceDate ? `收盘价 · ${row.priceDate}` : '收盘价';
104+
}
105+
return row.priceSource || '未知来源';
106+
}
107+
83108
function formatSideLabel(value: PortfolioSide): string {
84109
return value === 'buy' ? '买入' : '卖出';
85110
}
@@ -979,10 +1004,23 @@ const PortfolioPage: React.FC = () => {
9791004
<td className="py-2 pr-2 font-mono text-foreground">{row.symbol}</td>
9801005
<td className="py-2 pr-2 text-right">{row.quantity.toFixed(2)}</td>
9811006
<td className="py-2 pr-2 text-right">{row.avgCost.toFixed(4)}</td>
982-
<td className="py-2 pr-2 text-right">{row.lastPrice.toFixed(4)}</td>
983-
<td className="py-2 pr-2 text-right">{formatMoney(row.marketValueBase, row.valuationCurrency)}</td>
984-
<td className={`py-2 text-right ${row.unrealizedPnlBase >= 0 ? 'text-success' : 'text-danger'}`}>
985-
{formatMoney(row.unrealizedPnlBase, row.valuationCurrency)}
1007+
<td className="py-2 pr-2 text-right">
1008+
<div>{formatPositionPrice(row)}</div>
1009+
<div className={`text-[11px] ${hasPositionPrice(row) ? 'text-secondary' : 'text-warning'}`}>
1010+
{getPositionPriceLabel(row)}
1011+
</div>
1012+
</td>
1013+
<td className="py-2 pr-2 text-right">{formatPositionMoney(row.marketValueBase, row)}</td>
1014+
<td
1015+
className={`py-2 text-right ${
1016+
hasPositionPrice(row)
1017+
? row.unrealizedPnlBase >= 0
1018+
? 'text-success'
1019+
: 'text-danger'
1020+
: 'text-secondary'
1021+
}`}
1022+
>
1023+
{formatPositionMoney(row.unrealizedPnlBase, row)}
9861024
</td>
9871025
</tr>
9881026
))}

apps/dsa-web/src/types/portfolio.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export interface PortfolioPositionItem {
3838
marketValueBase: number;
3939
unrealizedPnlBase: number;
4040
valuationCurrency: string;
41+
priceSource?: 'realtime_quote' | 'history_close' | 'missing' | string;
42+
priceProvider?: string | null;
43+
priceDate?: string | null;
44+
priceStale?: boolean;
45+
priceAvailable?: boolean;
4146
}
4247

4348
export interface PortfolioAccountSnapshot {

docs/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1616
- [测试] 新增 `tests/test_bot_market_command.py`,覆盖 `MARKET_REVIEW_REGION=both` + open markets `{"cn","us"}` / `{"cn","hk"}``override_region` 透传断言,并覆盖全市场休市跳过与关闭交易日检查路径;新增 `tests/test_yfinance_hk_indices.py` 覆盖港股指数符号映射与部分/全部失败降级路径。
1717
- [修复] 问股 Agent 在未配置可用 LLM 时保留后端真实错误原因并维持 `done.success=false` 失败语义,避免前端把配置缺失误当成成功回答。
1818
- [文档] 补充 LLM 配置指南与 FAQ,明确问股 Agent 对 `LITELLM_CONFIG` / `LLM_CHANNELS` / legacy `GEMINI_*` `OPENAI_*` `ANTHROPIC_*` 的兼容优先级、回退路径与“不静默迁移旧配置”的结论。
19+
- [修复] Agent 模式未生成有效决策仪表盘时保留本地趋势分析的评分、趋势和操作建议,并将强买/强卖 fallback 归一到兼容的 `buy`/`sell` 决策类型,避免首页结果被 `50 / 观望 / 未知` 缺省值覆盖。
20+
- [修复] 持仓快照现价缺失时不再静默回退为持仓成本;当天快照优先使用历史收盘价,仅在缺失时使用实时价 fallback,缺价持仓不再污染市值与未实现盈亏汇总,并为持仓明细返回价格来源、日期、stale 与缺价状态。
21+
- [测试] 补齐 `task_queue` 轻量导入 stub 的股票代码规范化函数,恢复 `tests/test_task_queue_config_sync.py` 收集与运行。
1922

2023
## [3.14.1] - 2026-04-26
2124
- [测试] 修正大盘复盘 prompt 测试对“明日交易计划”标题的断言,并同步桌面端版本号,恢复发布 gate。

docs/full-guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,6 +1189,7 @@ A: 检查是否启用了 Actions,以及 cron 表达式是否正确(注意是
11891189
- 导入流程会先把 CSV 解析成标准化记录,再逐条提交到持仓账本;遇到忙碌行会计入 `failed_count`,不会因为单行冲突让整批请求整体失败。
11901190
- 交易去重优先使用账户内唯一的 `trade_uid`,缺失时回退到基于日期、代码、方向、数量、价格、费用、税费、币种的确定性哈希。
11911191
- 卖出会先校验可用数量,超卖返回 `409 portfolio_oversell`;并发写入冲突时可能返回 `409 portfolio_busy`。
1192+
- 持仓快照的 `positions[]` 会返回 `price_source`、`price_date`、`price_stale`、`price_available` 等价格元信息;当天快照优先使用历史收盘价,仅在收盘价缺失时尝试实时价 fallback,历史 `as_of` 快照不会拉取实时价,也不会再把成本价静默当作现价;缺价持仓会标记 `price_available=false` 并从市值与未实现盈亏汇总中排除。
11921193
- 汇率刷新会先尝试在线源;若在线获取失败,则回退到最近一次缓存并标记 `is_stale=true`,避免快照和风险页整体不可用。
11931194
- 当 `PORTFOLIO_FX_UPDATE_ENABLED=false` 时,手动刷新接口会明确返回“在线刷新已禁用”,页面不会误导为“当前没有可刷新的汇率对”。
11941195
- 风险摘要包含集中度、回撤、止损接近度等信息;`sector_concentration` 会优先尝试按板块归类,失败时降级到 `UNCLASSIFIED`,不会阻断风险结果返回。

docs/full-guide_EN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,7 @@ A: Check if Actions is enabled, and if cron expression is correct (note it's UTC
984984
- The button calls the existing `POST /api/v1/portfolio/fx/refresh` endpoint and reloads snapshot/risk data only.
985985
- If upstream FX fetch fails, the page may still remain stale after refresh and will explain the fallback result inline.
986986
- When `PORTFOLIO_FX_UPDATE_ENABLED=false`, the refresh API returns an explicit disabled status and the page shows that online FX refresh is disabled instead of implying that no refreshable pairs exist.
987+
- Portfolio snapshot `positions[]` now includes price metadata such as `price_source`, `price_date`, `price_stale`, and `price_available`. Today's snapshot uses the historical close first and only falls back to realtime quotes when no close exists, while historical `as_of` snapshots stay on historical-close semantics and no longer silently treat cost basis as the current price. Missing-price positions are marked with `price_available=false` and excluded from market value / unrealized PnL totals.
987988

988989
## Agent Tool Data Cache And Persistence
989990

src/core/pipeline.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,14 @@ def _analyze_with_agent(
822822
agent_result = executor.run(message, context=initial_context)
823823

824824
# 转换为 AnalysisResult
825-
result = self._agent_result_to_analysis_result(agent_result, code, stock_name, report_type, query_id)
825+
result = self._agent_result_to_analysis_result(
826+
agent_result,
827+
code,
828+
stock_name,
829+
report_type,
830+
query_id,
831+
trend_result=trend_result,
832+
)
826833
if result:
827834
result.query_id = query_id
828835
# Agent weak integrity: placeholder fill only, no LLM retry
@@ -892,7 +899,13 @@ def _analyze_with_agent(
892899
return None
893900

894901
def _agent_result_to_analysis_result(
895-
self, agent_result, code: str, stock_name: str, report_type: ReportType, query_id: str
902+
self,
903+
agent_result,
904+
code: str,
905+
stock_name: str,
906+
report_type: ReportType,
907+
query_id: str,
908+
trend_result: Optional[TrendAnalysisResult] = None,
896909
) -> AnalysisResult:
897910
"""
898911
将 AgentResult 转换为 AnalysisResult。
@@ -952,13 +965,57 @@ def _agent_result_to_analysis_result(
952965
# structure, so we unwrap it here.
953966
result.dashboard = dash.get("dashboard") or dash
954967
else:
955-
result.sentiment_score = 50
956-
result.operation_advice = "Watch" if report_language == "en" else "观望"
968+
self._apply_trend_fallback(result, trend_result, report_language)
957969
if not result.error_message:
958970
result.error_message = "Agent failed to generate a valid decision dashboard" if report_language == "en" else "Agent 未能生成有效的决策仪表盘"
959971

960972
return result
961973

974+
@staticmethod
975+
def _apply_trend_fallback(
976+
result: AnalysisResult,
977+
trend_result: Optional[TrendAnalysisResult],
978+
report_language: str,
979+
) -> None:
980+
if trend_result is None:
981+
result.sentiment_score = 50
982+
result.operation_advice = "Watch" if report_language == "en" else "观望"
983+
return
984+
985+
score = getattr(trend_result, "signal_score", None)
986+
try:
987+
numeric_score = int(score)
988+
except (TypeError, ValueError):
989+
numeric_score = 50
990+
result.sentiment_score = numeric_score if numeric_score > 0 else 50
991+
992+
trend_status = getattr(trend_result, "trend_status", None)
993+
trend_label = getattr(trend_status, "value", None) or str(trend_status or "").strip()
994+
if trend_label:
995+
result.trend_prediction = trend_label
996+
997+
buy_signal = getattr(trend_result, "buy_signal", None)
998+
signal_label = getattr(buy_signal, "value", None) or str(buy_signal or "").strip()
999+
if signal_label:
1000+
result.operation_advice = signal_label
1001+
else:
1002+
result.operation_advice = "Watch" if report_language == "en" else "观望"
1003+
1004+
from src.agent.protocols import normalize_decision_signal
1005+
1006+
signal_name = getattr(buy_signal, "name", "").lower()
1007+
signal_to_decision = {
1008+
"strong_buy": "buy",
1009+
"buy": "buy",
1010+
"hold": "hold",
1011+
"wait": "hold",
1012+
"sell": "sell",
1013+
"strong_sell": "sell",
1014+
}
1015+
result.decision_type = signal_to_decision.get(signal_name, result.decision_type or "hold")
1016+
result.decision_type = normalize_decision_signal(result.decision_type)
1017+
result.data_sources = f"{result.data_sources},trend:fallback" if result.data_sources else "trend:fallback"
1018+
9621019
@staticmethod
9631020
def _is_placeholder_stock_name(name: str, code: str) -> bool:
9641021
"""Return True when the stock name is missing or placeholder-like."""

src/repositories/portfolio_repo.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,10 @@ def query_corporate_actions(
687687
# Price / FX
688688
# ------------------------------------------------------------------
689689
def get_latest_close(self, symbol: str, as_of: date) -> Optional[float]:
690+
close = self.get_latest_close_with_date(symbol=symbol, as_of=as_of)
691+
return close[0] if close is not None else None
692+
693+
def get_latest_close_with_date(self, symbol: str, as_of: date) -> Optional[Tuple[float, date]]:
690694
with self.db.get_session() as session:
691695
row = session.execute(
692696
select(StockDaily)
@@ -701,7 +705,7 @@ def get_latest_close(self, symbol: str, as_of: date) -> Optional[float]:
701705
).scalar_one_or_none()
702706
if row is None or row.close is None:
703707
return None
704-
return float(row.close)
708+
return float(row.close), row.date
705709

706710
def save_fx_rate(
707711
self,

src/services/portfolio_service.py

Lines changed: 98 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ class _AvgState:
7070
total_cost: float = 0.0
7171

7272

73+
@dataclass(frozen=True)
74+
class _ResolvedPositionPrice:
75+
price: float
76+
source: str
77+
price_date: Optional[date]
78+
is_stale: bool
79+
is_available: bool
80+
provider: Optional[str] = None
81+
82+
7383
class PortfolioService:
7484
"""Business logic for account CRUD, event writes, and snapshot replay."""
7585

@@ -987,25 +997,29 @@ def _build_positions(
987997
}
988998
)
989999

990-
last_price = self.repo.get_latest_close(symbol=symbol, as_of=as_of_date)
991-
if last_price is None or last_price <= 0:
992-
last_price = avg_cost
1000+
price_info = self._resolve_position_price(symbol=symbol, as_of_date=as_of_date)
1001+
last_price = price_info.price
9931002

994-
local_market_value = qty * float(last_price)
995-
market_base, stale_market, _ = self._convert_amount(
996-
amount=local_market_value,
997-
from_currency=currency,
998-
to_currency=account.base_currency,
999-
as_of_date=as_of_date,
1000-
)
1001-
cost_base, stale_cost, _ = self._convert_amount(
1002-
amount=total_cost,
1003-
from_currency=currency,
1004-
to_currency=account.base_currency,
1005-
as_of_date=as_of_date,
1006-
)
1007-
unrealized_base = market_base - cost_base
1008-
fx_stale = fx_stale or stale_market or stale_cost
1003+
if price_info.is_available:
1004+
local_market_value = qty * float(last_price)
1005+
market_base, stale_market, _ = self._convert_amount(
1006+
amount=local_market_value,
1007+
from_currency=currency,
1008+
to_currency=account.base_currency,
1009+
as_of_date=as_of_date,
1010+
)
1011+
cost_base, stale_cost, _ = self._convert_amount(
1012+
amount=total_cost,
1013+
from_currency=currency,
1014+
to_currency=account.base_currency,
1015+
as_of_date=as_of_date,
1016+
)
1017+
unrealized_base = market_base - cost_base
1018+
fx_stale = fx_stale or stale_market or stale_cost
1019+
else:
1020+
market_base = 0.0
1021+
cost_base = 0.0
1022+
unrealized_base = 0.0
10091023

10101024
position_rows.append(
10111025
{
@@ -1019,6 +1033,11 @@ def _build_positions(
10191033
"market_value_base": round(market_base, 8),
10201034
"unrealized_pnl_base": round(unrealized_base, 8),
10211035
"valuation_currency": account.base_currency,
1036+
"price_source": price_info.source,
1037+
"price_provider": price_info.provider,
1038+
"price_date": price_info.price_date.isoformat() if price_info.price_date else None,
1039+
"price_stale": price_info.is_stale,
1040+
"price_available": price_info.is_available,
10221041
}
10231042
)
10241043

@@ -1027,6 +1046,67 @@ def _build_positions(
10271046

10281047
return position_rows, lot_rows, market_value_base, total_cost_base, fx_stale
10291048

1049+
def _resolve_position_price(self, *, symbol: str, as_of_date: date) -> _ResolvedPositionPrice:
1050+
today = date.today()
1051+
1052+
close = self.repo.get_latest_close_with_date(symbol=symbol, as_of=as_of_date)
1053+
if close is not None:
1054+
close_price, close_date = close
1055+
if close_price > 0:
1056+
return _ResolvedPositionPrice(
1057+
price=float(close_price),
1058+
source="history_close",
1059+
price_date=close_date,
1060+
is_stale=close_date < as_of_date,
1061+
is_available=True,
1062+
)
1063+
1064+
if as_of_date == today:
1065+
realtime_price, provider = self._fetch_realtime_position_price(symbol)
1066+
if realtime_price is not None and realtime_price > 0:
1067+
return _ResolvedPositionPrice(
1068+
price=float(realtime_price),
1069+
source="realtime_quote",
1070+
price_date=today,
1071+
is_stale=False,
1072+
is_available=True,
1073+
provider=provider,
1074+
)
1075+
1076+
return _ResolvedPositionPrice(
1077+
price=0.0,
1078+
source="missing",
1079+
price_date=None,
1080+
is_stale=True,
1081+
is_available=False,
1082+
)
1083+
1084+
@staticmethod
1085+
def _fetch_realtime_position_price(symbol: str) -> Tuple[Optional[float], Optional[str]]:
1086+
try:
1087+
from data_provider.base import DataFetcherManager
1088+
1089+
quote = DataFetcherManager().get_realtime_quote(symbol, log_final_failure=False)
1090+
except Exception as exc:
1091+
logger.warning("Failed to fetch realtime portfolio price for %s: %s", symbol, exc)
1092+
return None, None
1093+
1094+
if quote is None:
1095+
return None, None
1096+
1097+
price = getattr(quote, "price", None)
1098+
try:
1099+
numeric_price = float(price)
1100+
except (TypeError, ValueError):
1101+
return None, None
1102+
1103+
if numeric_price <= 0:
1104+
return None, None
1105+
1106+
source = getattr(quote, "source", None)
1107+
provider = getattr(source, "value", None) or (str(source) if source is not None else None)
1108+
return numeric_price, provider
1109+
10301110
@staticmethod
10311111
def _consume_fifo_lots(
10321112
lots: List[Dict[str, Any]],

0 commit comments

Comments
 (0)