feat: 支持日本/韩国 suffix-only 个股分析 (#1720)

* feat: support JP/KR suffix stock analysis

* fix(review-feedback-1720): 解决冲突后再合入
This commit is contained in:
mumu
2026-06-19 11:59:59 +08:00
committed by GitHub
parent cc98a26cf1
commit e49301a79f
25 changed files with 402 additions and 46 deletions

View File

@@ -16,7 +16,7 @@ DecisionSignalSourceType = Literal["analysis", "agent", "alert", "market_review"
DecisionSignalStatus = Literal["active", "expired", "invalidated", "closed", "archived"]
DecisionSignalPlanQuality = Literal["complete", "partial", "minimal", "unknown"]
DecisionSignalHorizon = Literal["intraday", "1d", "3d", "5d", "10d", "swing", "long"]
DecisionSignalMarket = Literal["cn", "hk", "us"]
DecisionSignalMarket = Literal["cn", "hk", "us", "jp", "kr"]
DecisionSignalOutcomeStatus = Literal["completed", "unable"]
DecisionSignalOutcomeValue = Literal["hit", "miss", "neutral"]
DecisionSignalFeedbackValue = Literal["useful", "not_useful"]

View File

@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field
SourceTypeValue = Literal["rss", "atom", "newsnow"]
ScopeTypeValue = Literal["symbol", "market", "sector"]
MarketValue = Literal["cn", "hk", "us", "global"]
MarketValue = Literal["cn", "hk", "us", "jp", "kr", "global"]
class IntelligenceSourceCreateRequest(BaseModel):

View File

@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field
class PortfolioAccountCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
broker: Optional[str] = Field(None, max_length=64)
market: Literal["cn", "hk", "us"] = "cn"
market: Literal["cn", "hk", "us", "jp", "kr"] = "cn"
base_currency: str = Field("CNY", min_length=3, max_length=8)
owner_id: Optional[str] = Field(None, max_length=64)
@@ -20,7 +20,7 @@ class PortfolioAccountCreateRequest(BaseModel):
class PortfolioAccountUpdateRequest(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64)
broker: Optional[str] = Field(None, max_length=64)
market: Optional[Literal["cn", "hk", "us"]] = None
market: Optional[Literal["cn", "hk", "us", "jp", "kr"]] = None
base_currency: Optional[str] = Field(None, min_length=3, max_length=8)
owner_id: Optional[str] = Field(None, max_length=64)
is_active: Optional[bool] = None
@@ -51,7 +51,7 @@ class PortfolioTradeCreateRequest(BaseModel):
price: float = Field(..., gt=0)
fee: float = Field(0.0, ge=0)
tax: float = Field(0.0, ge=0)
market: Optional[Literal["cn", "hk", "us"]] = None
market: Optional[Literal["cn", "hk", "us", "jp", "kr"]] = None
currency: Optional[str] = Field(None, min_length=3, max_length=8)
trade_uid: Optional[str] = Field(None, max_length=128)
note: Optional[str] = Field(None, max_length=255)
@@ -71,7 +71,7 @@ class PortfolioCorporateActionCreateRequest(BaseModel):
symbol: str = Field(..., min_length=1, max_length=16)
effective_date: date
action_type: Literal["cash_dividend", "split_adjustment"]
market: Optional[Literal["cn", "hk", "us"]] = None
market: Optional[Literal["cn", "hk", "us", "jp", "kr"]] = None
currency: Optional[str] = Field(None, min_length=3, max_length=8)
cash_dividend_per_share: Optional[float] = Field(None, ge=0)
split_ratio: Optional[float] = Field(None, gt=0)

View File

@@ -30,7 +30,7 @@ export const ALERT_TYPE_LABELS: Record<UiLanguage, Record<AlertType, string>> =
};
export const ALERT_SEVERITY_LABELS: Record<UiLanguage, Record<string, string>> = { zh: { info: '提示', warning: '警告', critical: '严重' }, en: { info: 'Info', warning: 'Warning', critical: 'Critical' } };
export const ALERT_SCOPE_LABELS: Record<UiLanguage, Record<AlertTargetScope, string>> = { zh: { single_symbol: '单标的', watchlist: '自选股', portfolio_holdings: '持仓标的', portfolio_account: '持仓账户', market: '大盘市场' }, en: { single_symbol: 'Single symbol', watchlist: 'Watchlist', portfolio_holdings: 'Portfolio holdings', portfolio_account: 'Portfolio account', market: 'Market' } };
export const ALERT_MARKET_REGION_LABELS: Record<UiLanguage, Record<MarketRegion, string>> = { zh: { cn: 'A 股', hk: '港股', us: '美股' }, en: { cn: 'A-shares', hk: 'Hong Kong', us: 'US' } };
export const ALERT_MARKET_REGION_LABELS: Record<UiLanguage, Record<MarketRegion, string>> = { zh: { cn: 'A 股', hk: '港股', us: '美股', jp: '日股', kr: '韩股' }, en: { cn: 'A-shares', hk: 'Hong Kong', us: 'US', jp: 'Japan', kr: 'Korea' } };
export const ALERT_MARKET_LIGHT_STATUS_LABELS: Record<UiLanguage, Record<MarketLightStatus, string>> = { zh: { yellow: '黄灯', red: '红灯' }, en: { yellow: 'Yellow', red: 'Red' } };
export const ALERT_DIRECTION_LABELS = {
zh: { abovePrice: '上破', belowPrice: '下破', upChange: '上涨', downChange: '下跌', aboveThreshold: '上穿', belowThreshold: '下穿', bullishCross: '金叉', bearishCross: '死叉', stopLossNear: '接近止损', stopLossBreach: '已触发止损' },
@@ -56,7 +56,7 @@ export const ALERT_CHANGE_DIRECTION_OPTIONS: Record<UiLanguage, Array<Option<'up
export const ALERT_THRESHOLD_DIRECTION_OPTIONS: Record<UiLanguage, Array<Option<'above' | 'below'>>> = { zh: [{ value: 'above', label: '上穿' }, { value: 'below', label: '下穿' }], en: [{ value: 'above', label: 'Crosses above' }, { value: 'below', label: 'Crosses below' }] };
export const ALERT_CROSS_DIRECTION_OPTIONS: Record<UiLanguage, Array<Option<'bullish_cross' | 'bearish_cross'>>> = { zh: [{ value: 'bullish_cross', label: '金叉' }, { value: 'bearish_cross', label: '死叉' }], en: [{ value: 'bullish_cross', label: 'Bullish cross' }, { value: 'bearish_cross', label: 'Bearish cross' }] };
export const ALERT_STOP_LOSS_MODE_OPTIONS: Record<UiLanguage, Array<Option<PortfolioStopLossMode>>> = { zh: [{ value: 'near', label: '接近止损' }, { value: 'breach', label: '已触发止损' }], en: [{ value: 'near', label: 'Near stop loss' }, { value: 'breach', label: 'Stop loss breached' }] };
export const ALERT_MARKET_REGION_OPTIONS: Record<UiLanguage, Array<Option<MarketRegion>>> = { zh: [{ value: 'cn', label: 'A 股cn' }, { value: 'hk', label: '港股hk' }, { value: 'us', label: '美股us' }], en: [{ value: 'cn', label: 'A-shares (cn)' }, { value: 'hk', label: 'Hong Kong (hk)' }, { value: 'us', label: 'US (us)' }] };
export const ALERT_MARKET_REGION_OPTIONS: Record<UiLanguage, Array<Option<MarketRegion>>> = { zh: [{ value: 'cn', label: 'A 股cn' }, { value: 'hk', label: '港股hk' }, { value: 'us', label: '美股us' }, { value: 'jp', label: '日股jp' }, { value: 'kr', label: '韩股kr' }], en: [{ value: 'cn', label: 'A-shares (cn)' }, { value: 'hk', label: 'Hong Kong (hk)' }, { value: 'us', label: 'US (us)' }, { value: 'jp', label: 'Japan (jp)' }, { value: 'kr', label: 'Korea (kr)' }] };
export const ALERT_MARKET_LIGHT_STATUS_OPTIONS: Record<UiLanguage, Array<Option<MarketLightStatus>>> = { zh: [{ value: 'red', label: '红灯' }, { value: 'yellow', label: '黄灯' }], en: [{ value: 'red', label: 'Red' }, { value: 'yellow', label: 'Yellow' }] };
export const PORTFOLIO_TEXT = {

View File

@@ -57,7 +57,7 @@ type SelectedSignal = {
source: 'list' | 'latest';
};
const MARKET_OPTIONS: DecisionSignalMarket[] = ['cn', 'hk', 'us'];
const MARKET_OPTIONS: DecisionSignalMarket[] = ['cn', 'hk', 'us', 'jp', 'kr'];
const ACTION_OPTIONS: DecisionAction[] = ['buy', 'add', 'hold', 'reduce', 'sell', 'watch', 'avoid', 'alert'];
const PHASE_OPTIONS: MarketPhaseValue[] = ['premarket', 'intraday', 'lunch_break', 'closing_auction', 'postmarket', 'non_trading', 'unknown'];
const SOURCE_OPTIONS: DecisionSignalSourceType[] = ['analysis', 'agent', 'alert', 'market_review', 'manual'];

View File

@@ -111,7 +111,8 @@ function isNewerSignal(left: DecisionSignalItem | undefined, right: DecisionSign
return getSignalTime(right) > getSignalTime(left);
}
const DECISION_SIGNAL_MARKETS = new Set<DecisionSignalMarket>(['cn', 'hk', 'us']);
const DECISION_SIGNAL_MARKETS = new Set<DecisionSignalMarket>(['cn', 'hk', 'us', 'jp', 'kr']);
type PortfolioAccountMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr';
function toDecisionSignalMarket(value: string | null | undefined): DecisionSignalMarket | undefined {
const normalized = String(value || '').toLowerCase();
@@ -174,7 +175,7 @@ const PortfolioPage: React.FC = () => {
const [accountForm, setAccountForm] = useState({
name: '',
broker: 'Demo',
market: 'cn' as 'cn' | 'hk' | 'us',
market: 'cn' as PortfolioAccountMarket,
baseCurrency: 'CNY',
});
const [costMethod, setCostMethod] = useState<PortfolioCostMethod>('fifo');
@@ -1073,11 +1074,13 @@ const PortfolioPage: React.FC = () => {
<select
className={PORTFOLIO_SELECT_CLASS}
value={accountForm.market}
onChange={(e) => setAccountForm((prev) => ({ ...prev, market: e.target.value as 'cn' | 'hk' | 'us' }))}
onChange={(e) => setAccountForm((prev) => ({ ...prev, market: e.target.value as PortfolioAccountMarket }))}
>
<option value="cn">A cn</option>
<option value="hk">hk</option>
<option value="us">us</option>
<option value="jp">jp</option>
<option value="kr">kr</option>
</select>
<button type="submit" className="btn-secondary text-sm" disabled={accountCreating}>
{accountCreating ? '创建中...' : '创建账户'}

View File

@@ -97,7 +97,7 @@ vi.mock('recharts', () => ({
type AccountItem = {
id: number;
name: string;
market?: 'cn' | 'hk' | 'us';
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
baseCurrency?: string;
};

View File

@@ -20,7 +20,7 @@ export type AlertSeverity = 'info' | 'warning' | 'critical';
export type AlertTargetScope = 'single_symbol' | 'watchlist' | 'portfolio_holdings' | 'portfolio_account' | 'market';
export type AlertDirection = 'above' | 'below' | 'up' | 'down' | 'bullish_cross' | 'bearish_cross';
export type PortfolioStopLossMode = 'near' | 'breach';
export type MarketRegion = 'cn' | 'hk' | 'us';
export type MarketRegion = 'cn' | 'hk' | 'us' | 'jp' | 'kr';
export type MarketLightStatus = 'yellow' | 'red';
export type AlertDryRunStatus = 'triggered' | 'not_triggered' | 'evaluation_error';
export type AlertTriggerStatus = 'triggered' | 'skipped' | 'degraded' | 'failed';

View File

@@ -10,7 +10,7 @@ export type DecisionSignalSourceType = 'analysis' | 'agent' | 'alert' | 'market_
export type DecisionSignalStatus = 'active' | 'expired' | 'invalidated' | 'closed' | 'archived';
export type DecisionSignalPlanQuality = 'complete' | 'partial' | 'minimal' | 'unknown';
export type DecisionSignalHorizon = 'intraday' | '1d' | '3d' | '5d' | '10d' | 'swing' | 'long';
export type DecisionSignalMarket = 'cn' | 'hk' | 'us';
export type DecisionSignalMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr';
export type DecisionSignalOutcomeEvalStatus = 'completed' | 'unable';
export type DecisionSignalOutcomeValue = 'hit' | 'miss' | 'neutral';
export type DecisionSignalFeedbackValue = 'useful' | 'not_useful';

View File

@@ -10,7 +10,7 @@ export interface PortfolioAccountItem {
ownerId?: string | null;
name: string;
broker?: string | null;
market: 'cn' | 'hk' | 'us';
market: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
baseCurrency: string;
isActive: boolean;
createdAt?: string | null;
@@ -24,7 +24,7 @@ export interface PortfolioAccountListResponse {
export interface PortfolioAccountCreateRequest {
name: string;
broker?: string;
market: 'cn' | 'hk' | 'us';
market: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
baseCurrency: string;
ownerId?: string;
}
@@ -181,7 +181,7 @@ export interface PortfolioTradeCreateRequest {
price: number;
fee?: number;
tax?: number;
market?: 'cn' | 'hk' | 'us';
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
currency?: string;
tradeUid?: string;
note?: string;
@@ -201,7 +201,7 @@ export interface PortfolioCorporateActionCreateRequest {
symbol: string;
effectiveDate: string;
actionType: PortfolioCorporateActionType;
market?: 'cn' | 'hk' | 'us';
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
currency?: string;
cashDividendPerShare?: number;
splitRatio?: number;

View File

@@ -52,6 +52,14 @@ describe('normalizeStockCode', () => {
expect(normalizeStockCode('BRK.B')).toBe('BRK.B');
});
it('keeps JP/KR Yahoo suffix codes in canonical uppercase suffix form', () => {
expect(normalizeStockCode('7203.T')).toBe('7203.T');
expect(normalizeStockCode('6758.t')).toBe('6758.T');
expect(normalizeStockCode('005930.KS')).toBe('005930.KS');
expect(normalizeStockCode('035720.kq')).toBe('035720.KQ');
expect(normalizeStockCode('005930')).toBe('005930');
});
it('is case-insensitive for prefixes', () => {
expect(normalizeStockCode('sh600519')).toBe('600519');
expect(normalizeStockCode('sz000001')).toBe('000001');
@@ -75,6 +83,9 @@ describe('normalizeStockCode', () => {
expect(areStockCodesEquivalent('00700', 'HK00700')).toBe(true);
expect(areStockCodesEquivalent('01810', '1810.HK')).toBe(true);
expect(areStockCodesEquivalent('aapl', 'AAPL')).toBe(true);
expect(areStockCodesEquivalent('7203.t', '7203.T')).toBe(true);
expect(areStockCodesEquivalent('005930.ks', '005930.KS')).toBe(true);
expect(areStockCodesEquivalent('005930', '005930.KS')).toBe(false);
expect(areStockCodesEquivalent('00700', 'HK01810')).toBe(false);
expect(areStockCodesEquivalent('', 'HK00700')).toBe(false);
});

View File

@@ -10,6 +10,7 @@
* HK00700 → HK00700 00700 → HK00700
* 00700.HK → HK00700
* hk1810 → HK01810 1810.HK → HK01810
* 7203.T → 7203.T 005930.KS → 005930.KS
* AAPL → AAPL TSLA → TSLA
*/
export function normalizeStockCode(stockCode: string): string {
@@ -67,6 +68,14 @@ export function normalizeStockCode(stockCode: string): string {
const base = code.slice(0, dotIndex);
const suffix = code.slice(dotIndex + 1).toUpperCase();
// JP/KR Yahoo suffix-only codes are canonical as uppercase suffix forms.
if (suffix === 'T' && /^\d{4,5}$/.test(base)) {
return `${base}.${suffix}`;
}
if ((suffix === 'KS' || suffix === 'KQ') && /^\d{6}$/.test(base)) {
return `${base}.${suffix}`;
}
// 00700.HK → HK00700
if (suffix === 'HK' && /^\d{1,5}$/.test(base)) {
return `HK${base.padStart(5, '0')}`;

View File

@@ -84,6 +84,8 @@ def normalize_stock_code(stock_code: str) -> str:
- '920748.BJ' -> '920748' (strip .BJ suffix, BSE)
- 'HK00700' -> 'HK00700' (keep HK prefix for HK stocks)
- '1810.HK' -> 'HK01810' (normalize HK suffix to canonical prefix form)
- '7203.T' -> '7203.T' (keep Japan Yahoo suffix form)
- '005930.KS' -> '005930.KS' (keep Korea Yahoo suffix form)
- 'AAPL' -> 'AAPL' (keep US stock ticker as-is)
This function is applied at the DataProviderManager layer so that
@@ -124,8 +126,13 @@ def normalize_stock_code(stock_code: str) -> str:
return candidate
# Strip .SH/.SZ/.BJ suffix (e.g. 600519.SH -> 600519, 920748.BJ -> 920748)
# while preserving explicit Yahoo suffix forms for JP/KR.
if '.' in code:
base, suffix = code.rsplit('.', 1)
if suffix.upper() == 'T' and base.isdigit() and len(base) in (4, 5):
return f"{base}.{suffix.upper()}"
if suffix.upper() in ('KS', 'KQ') and base.isdigit() and len(base) == 6:
return f"{base}.{suffix.upper()}"
if suffix.upper() == 'HK' and base.isdigit() and 1 <= len(base) <= 5:
return f"HK{base.zfill(5)}"
if base.upper() in ('SH', 'SS', 'SZ', 'BJ') and suffix.isdigit():
@@ -165,6 +172,24 @@ def _is_hk_market(code: str) -> bool:
return False
def _is_jp_market(code: str) -> bool:
"""判定是否为日本 Yahoo Finance suffix 代码(如 7203.T"""
normalized = (code or "").strip().upper()
if not normalized.endswith(".T"):
return False
base = normalized[:-2]
return base.isdigit() and len(base) in (4, 5)
def _is_kr_market(code: str) -> bool:
"""判定是否为韩国 Yahoo Finance suffix 代码(如 005930.KS / 035720.KQ"""
normalized = (code or "").strip().upper()
if not normalized.endswith((".KS", ".KQ")):
return False
base = normalized.rsplit(".", 1)[0]
return base.isdigit() and len(base) == 6
def _is_etf_code(code: str) -> bool:
"""判定 A 股 ETF 基金代码(保守规则)。"""
normalized = normalize_stock_code(code)
@@ -205,11 +230,15 @@ def _is_meaningful_chip_distribution(chip: Any) -> bool:
def _market_tag(code: str) -> str:
"""返回市场标签: cn/us/hk."""
"""返回市场标签: cn/us/hk/jp/kr."""
if _is_us_market(code):
return "us"
if _is_hk_market(code):
return "hk"
if _is_jp_market(code):
return "jp"
if _is_kr_market(code):
return "kr"
return "cn"
@@ -581,7 +610,7 @@ class DataFetcherManager:
"TushareFetcher": {"cn", "hk"},
"PytdxFetcher": {"cn"},
"BaostockFetcher": {"cn"},
"YfinanceFetcher": {"cn", "hk", "us"},
"YfinanceFetcher": {"cn", "hk", "us", "jp", "kr"},
"LongbridgeFetcher": {"hk", "us"},
"FinnhubFetcher": {"us"},
"AlphaVantageFetcher": {"us"},
@@ -708,8 +737,6 @@ class DataFetcherManager:
market: str,
) -> List[BaseFetcher]:
"""Skip built-in daily fetchers that are known not to support a market."""
if market not in {"cn", "hk", "us"}:
return fetchers
kept: List[BaseFetcher] = []
skipped: List[str] = []
@@ -1218,9 +1245,11 @@ class DataFetcherManager:
is_us_index = is_us_index_code(stock_code)
is_us = is_us_index or is_us_stock_code(stock_code)
is_hk = (not is_us) and _is_hk_market(stock_code)
market = "us" if is_us else "hk" if is_hk else "cn"
if is_hk:
fetchers = self._filter_daily_fetchers_for_market(fetchers, "hk")
is_jp = (not is_us) and (not is_hk) and _is_jp_market(stock_code)
is_kr = (not is_us) and (not is_hk) and _is_kr_market(stock_code)
market = "us" if is_us else "hk" if is_hk else "jp" if is_jp else "kr" if is_kr else "cn"
if market != "cn":
fetchers = self._filter_daily_fetchers_for_market(fetchers, market)
fetchers = self._filter_fetchers_by_capability(fetchers, capability="daily_data")
total_fetchers = len(fetchers)
@@ -1652,6 +1681,21 @@ class DataFetcherManager:
is_us_index = is_us_index_code(stock_code)
is_us = is_us_index or _is_us_code(stock_code)
is_hk = (not is_us) and _is_hk_market(stock_code)
is_jp = (not is_us) and (not is_hk) and _is_jp_market(stock_code)
is_kr = (not is_us) and (not is_hk) and _is_kr_market(stock_code)
if is_jp or is_kr:
market_label = "日股" if is_jp else "韩股"
quote = self._try_fetcher_quote(stock_code, "YfinanceFetcher")
if quote is not None:
logger.info(f"[实时行情] {market_label} {stock_code} 成功获取 (来源: YfinanceFetcher)")
return self._enrich_realtime_quote(
quote,
realtime_cache_ttl=getattr(config, "realtime_cache_ttl", None),
)
if log_final_failure:
logger.info(f"[实时行情] {market_label} {stock_code} 无可用数据源")
return None
if is_us or is_hk:
prefer_lb = self._longbridge_preferred() and not is_us_index
@@ -2903,7 +2947,7 @@ class DataFetcherManager:
stock_code = normalize_stock_code(stock_code)
market = _market_tag(stock_code)
is_etf = _is_etf_code(stock_code)
if market in {"us", "hk"}:
if market in {"us", "hk", "jp", "kr"}:
return self._build_offshore_fundamental_context(
stock_code,
market=market,

View File

@@ -78,6 +78,18 @@ class YfinanceFetcher(BaseFetcher):
"""初始化 YfinanceFetcher"""
pass
@staticmethod
def _is_jp_kr_suffix_stock(stock_code: str) -> bool:
"""Return True for supported JP/KR suffix-only Yahoo symbols."""
code = (stock_code or "").strip().upper()
if code.endswith(".T"):
base = code[:-2]
return base.isdigit() and len(base) in (4, 5)
if code.endswith((".KS", ".KQ")):
base = code.rsplit(".", 1)[0]
return base.isdigit() and len(base) == 6
return False
def _convert_stock_code(self, stock_code: str) -> str:
"""
转换股票代码为 Yahoo Finance 格式
@@ -115,6 +127,11 @@ class YfinanceFetcher(BaseFetcher):
logger.debug(f"识别为美股代码: {code}")
return code
# 日股/韩股 MVP显式 Yahoo Finance suffix-only 代码,原样传给 Yahoo。
if self._is_jp_kr_suffix_stock(code):
logger.debug(f"识别为日韩 Yahoo suffix 代码: {code}")
return code
# 港股hk前缀 -> .HK后缀
if code.startswith('HK'):
hk_code = code[2:].lstrip('0') or '0' # 去除前导0但保留至少一个0
@@ -329,6 +346,10 @@ class YfinanceFetcher(BaseFetcher):
return self._get_us_main_indices(yf)
if region == "hk":
return self._get_hk_main_indices(yf)
if region == "jp":
return self._get_jp_main_indices(yf)
if region == "kr":
return self._get_kr_main_indices(yf)
# A 股指数akshare 代码 -> (yfinance 代码, 显示名称)
yf_mapping = {
@@ -419,6 +440,52 @@ class YfinanceFetcher(BaseFetcher):
return None
def _get_jp_main_indices(self, yf) -> Optional[List[Dict[str, Any]]]:
"""获取日本主要指数行情日经225、TOPIX复用 _fetch_yf_ticker_data。"""
jp_indices = {
'N225': ('^N225', '日经225'),
'TOPX': ('^TOPX', '东证指数'),
}
results = []
try:
for code, (yf_symbol, name) in jp_indices.items():
try:
item = self._fetch_yf_ticker_data(yf, yf_symbol, name, code)
if item:
results.append(item)
logger.debug(f"[Yfinance] 获取日本指数 {name} 成功")
except Exception as e:
logger.warning(f"[Yfinance] 获取日本指数 {name} 失败: {e}")
if results:
logger.info(f"[Yfinance] 成功获取 {len(results)} 个日本指数行情")
return results
except Exception as e:
logger.error(f"[Yfinance] 获取日本指数行情失败: {e}")
return None
def _get_kr_main_indices(self, yf) -> Optional[List[Dict[str, Any]]]:
"""获取韩国主要指数行情KOSPI、KOSDAQ复用 _fetch_yf_ticker_data。"""
kr_indices = {
'KS11': ('^KS11', 'KOSPI'),
'KQ11': ('^KQ11', 'KOSDAQ'),
}
results = []
try:
for code, (yf_symbol, name) in kr_indices.items():
try:
item = self._fetch_yf_ticker_data(yf, yf_symbol, name, code)
if item:
results.append(item)
logger.debug(f"[Yfinance] 获取韩国指数 {name} 成功")
except Exception as e:
logger.warning(f"[Yfinance] 获取韩国指数 {name} 失败: {e}")
if results:
logger.info(f"[Yfinance] 成功获取 {len(results)} 个韩国指数行情")
return results
except Exception as e:
logger.error(f"[Yfinance] 获取韩国指数行情失败: {e}")
return None
def _is_us_stock(self, stock_code: str) -> bool:
"""
判断代码是否为美股股票(排除美股指数)。
@@ -687,14 +754,15 @@ class YfinanceFetcher(BaseFetcher):
index_name=index_name,
)
# 仅处理美股股票
if not self._is_us_stock(stock_code):
logger.debug(f"[Yfinance] {stock_code} 不是美股,跳过")
# 仅处理美股股票或 JP/KR suffix-only 股票
if not (self._is_us_stock(stock_code) or self._is_jp_kr_suffix_stock(stock_code)):
logger.debug(f"[Yfinance] {stock_code} 不是美股或日韩 suffix 代码,跳过")
return None
try:
symbol = stock_code.strip().upper()
logger.debug(f"[Yfinance] 获取美股 {symbol} 实时行情")
symbol = self._convert_stock_code(stock_code)
is_us_symbol = self._is_us_stock(symbol)
logger.debug(f"[Yfinance] 获取 {symbol} 实时行情")
ticker = yf.Ticker(symbol)
@@ -717,8 +785,11 @@ class YfinanceFetcher(BaseFetcher):
logger.debug("[Yfinance] fast_info 失败,尝试 history 方法")
hist = ticker.history(period='2d')
if hist.empty:
logger.warning(f"[Yfinance] 无法获取 {symbol} 的数据,尝试 Stooq 兜底")
return self._get_us_stock_quote_from_stooq(symbol)
if is_us_symbol:
logger.warning(f"[Yfinance] 无法获取 {symbol} 的数据,尝试 Stooq 兜底")
return self._get_us_stock_quote_from_stooq(symbol)
logger.warning(f"[Yfinance] 无法获取 {symbol} 的数据")
return None
today = hist.iloc[-1]
prev = hist.iloc[-2] if len(hist) > 1 else today
@@ -772,12 +843,15 @@ class YfinanceFetcher(BaseFetcher):
circ_mv=None,
)
logger.info(f"[Yfinance] 获取美股 {symbol} 实时行情成功: 价格={price}")
logger.info(f"[Yfinance] 获取 {symbol} 实时行情成功: 价格={price}")
return quote
except Exception as e:
logger.warning(f"[Yfinance] 获取美股 {stock_code} 实时行情失败: {e},尝试 Stooq 兜底")
return self._get_us_stock_quote_from_stooq(stock_code)
if self._is_us_stock(stock_code):
logger.warning(f"[Yfinance] 获取美股 {stock_code} 实时行情失败: {e},尝试 Stooq 兜底")
return self._get_us_stock_quote_from_stooq(stock_code)
logger.warning(f"[Yfinance] 获取 {stock_code} 实时行情失败: {e}")
return None
if __name__ == "__main__":

View File

@@ -48,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [文档] #1707 阐明情报池接入仅追加本地资讯消费,不改模型名/provider/base URL/默认模型策略/回退策略/保存前清理逻辑或运行时配置迁移;结构化风险提示若出现为关键词误报;回滚可采用 `revert` 本 PR 或停用/移除本地资讯接入入口与数据。
- [文档] 本次 #1707 情报源基线仅新增 `NEWS_INTEL_*` 配置,不变更 LITELLM / ANSPIRE / Base URL / provider/model 清理与回退语义;回退策略为移除或禁用这三项情报源变量后恢复既有 LLM 配置行为。
- [修复] #1707 默认禁用 `/api/v1/intelligence/sources/defaults` 新建源(未传 `enabled`),避免公开示例 NewsNow 实例被默认启用,同时统一 500 响应细节仅入日志、响应返回通用错误信息。
- [新功能] #1718 新增日本/韩国 suffix-only 个股分析 MVP手输 `.T` / `.KS` / `.KQ` 代码可走 YFinance 日线与近实时行情补充市场识别、交易日历、Prompt 语义、Web/API 类型和能力边界文档。
- [文档] #1718 明确本次为 JP/KR suffix 仅路由兼容改造,不涉及 provider/model/base URL/运行时配置清理与保存前清理策略,不新增或变更 ANSPIRE/模型相关运行态配置项;回退方式为 revert 本次改动或移除 jp/kr 入口恢复既有行为。
- [新功能] 个股分析历史成功保存后会从最终报告 best-effort 提取 `DecisionSignal` 决策信号,复用现有信号去重、计划质量计算和脱敏契约。
- [修复] AlphaSift 热点详情兼容 `leader_stocks``stocks` 双字段,避免旧合约或缓存只提供其中一个字段时概念股详情报缺失字段。

View File

@@ -3789,7 +3789,9 @@
"enum": [
"cn",
"hk",
"us"
"us",
"jp",
"kr"
],
"title": "Market"
},
@@ -5661,7 +5663,9 @@
"enum": [
"cn",
"hk",
"us"
"us",
"jp",
"kr"
]
},
{

32
docs/market-support.md Normal file
View File

@@ -0,0 +1,32 @@
# 市场支持与边界
## 日本/韩国个股 suffix-only MVPIssue #1718
当前阶段支持手动输入日本、韩国股票的 Yahoo Finance 后缀代码,进入既有个股分析、历史保存和基础报告展示链路。
支持格式:
- 日本:`7203.T``6758.T`
- 韩国 KOSPI`005930.KS`
- 韩国 KOSDAQ`035720.KQ`
约束与边界:
- 韩国股票必须带 `.KS` / `.KQ` 后缀;裸 `005930` 不会被识别为韩国股票,会按既有 6 位数字代码规则落到 A 股语义,避免与 A 股代码体系冲突。
- 日股/韩股日线和基础实时/近实时行情只走 `YfinanceFetcher`,不尝试 AkShare、Tushare、Efinance、Pytdx、Baostock 等 A 股专属数据源。
- 基本面复用既有 offshore yfinance 轻量路径A 股专属资金流、龙虎榜、板块等能力按 `not_supported` 降级。
- 报告 Prompt 已增加日股/韩股市场语义,避免套用 A 股涨跌停、北向资金、龙虎榜、融资融券等概念。
- 交易日历注册 `jp: XTKS / Asia/Tokyo``kr: XKRX / Asia/Seoul`。若本地 `exchange-calendars` 版本缺少对应日历,既有 fail-open/fail-closed 语义保持不变。
不承诺项:
- 不承诺实时行情Yahoo Finance 数据可能延迟或字段缺失。
- 不承诺完整基本面、行业/板块、市场宽度、涨跌家数或日韩大盘复盘。
- 不包含日韩股票列表自动补全;没有索引前仅支持手动输入 suffix 代码。
- 不补齐 Portfolio 的 JPY/KRW 汇率、成本、市值完整口径;相关字段仅放开市场类型以避免前后端校验拒绝。
回滚方式:移除 `jp/kr` 市场识别、交易日历注册、YFinance 路由扩展和 Web/API 类型放行,并删除本文档中的能力声明。
配置兼容性:
- 本次改动仅新增 JP/KR suffix 的市场识别与路由分支,不新增或变更 `provider/model/base_url`、运行时配置清理策略、ANSPIRE 配置、以及模型接入层语义;本地 `.env` 与既有配置兼容性保持不变。

View File

@@ -36,13 +36,15 @@ except ImportError:
)
# Market -> exchange code (exchange-calendars)
MARKET_EXCHANGE = {"cn": "XSHG", "hk": "XHKG", "us": "XNYS"}
MARKET_EXCHANGE = {"cn": "XSHG", "hk": "XHKG", "us": "XNYS", "jp": "XTKS", "kr": "XKRX"}
# Market -> IANA timezone for "today"
MARKET_TIMEZONE = {
"cn": "Asia/Shanghai",
"hk": "Asia/Hong_Kong",
"us": "America/New_York",
"jp": "Asia/Tokyo",
"kr": "Asia/Seoul",
}
# P0 market phase baseline (Issue #1386). This is an intentionally small
@@ -111,7 +113,7 @@ def get_market_for_stock(code: str) -> Optional[str]:
Infer market region for a stock code.
Returns:
'cn' | 'hk' | 'us' | None (None = unrecognized, fail-open: treat as open)
'cn' | 'hk' | 'us' | 'jp' | 'kr' | None (None = unrecognized, fail-open: treat as open)
"""
if not code or not isinstance(code, str):
return None
@@ -123,6 +125,14 @@ def get_market_for_stock(code: str) -> Optional[str]:
return "us"
if is_hk_stock_code(code):
return "hk"
if code.endswith(".T"):
base = code[:-2]
if base.isdigit() and len(base) in (4, 5):
return "jp"
if code.endswith((".KS", ".KQ")):
base = code.rsplit(".", 1)[0]
if base.isdigit() and len(base) == 6:
return "kr"
# A-share: 6-digit numeric
if code.isdigit() and len(code) == 6:
return "cn"

View File

@@ -34,6 +34,13 @@ def detect_market(stock_code: Optional[str]) -> str:
if code.isdigit() and len(code) == 5:
return "hk"
# Japan/Korea suffix-only symbols supported by Yahoo Finance.
# Bare Korean six-digit codes remain A-share fallback to avoid collision.
if re.match(r'^\d{4,5}\.T$', code):
return "jp"
if re.match(r'^\d{6}\.(KS|KQ)$', code):
return "kr"
# US stocks: 1-5 uppercase letters (AAPL, TSLA, GOOGL)
# Also handles suffixed forms like BRK.B
if re.match(r'^[A-Z]{1,5}(\.[A-Z]{1,2})?$', code):
@@ -58,6 +65,14 @@ _MARKET_ROLES = {
"zh": "美股",
"en": "US stock",
},
"jp": {
"zh": "日股",
"en": "Japan stock",
},
"kr": {
"zh": "韩股",
"en": "Korea stock",
},
}
_MARKET_GUIDELINES = {
@@ -91,6 +106,26 @@ _MARKET_GUIDELINES = {
"- US stocks have no daily price limits (but have circuit breakers), allow T+0 and pre/after-market trading. Consider USD FX, Fed policy, and SEC regulations."
),
},
"jp": {
"zh": (
"- 本次分析对象为 **日股**日本交易所上市股票Yahoo Finance suffix 如 `.T`)。\n"
"- 请按日本市场语境分析,关注日元汇率、日本央行政策、公司治理与行业周期;不要套用 A 股涨跌停、北向资金、龙虎榜、融资融券等 A 股专属概念。"
),
"en": (
"- This analysis covers a **Japan stock** (Yahoo Finance suffix such as `.T`).\n"
"- Use Japan-market context: JPY FX, BOJ policy, corporate governance, and sector cycles; do not apply China A-share concepts such as daily price-limit boards, Northbound flows, Dragon Tiger lists, or margin-financing narratives."
),
},
"kr": {
"zh": (
"- 本次分析对象为 **韩股**(韩国交易所/KOSDAQ 上市股票,必须带 `.KS` / `.KQ` 后缀)。\n"
"- 请按韩国市场语境分析,关注韩元汇率、韩国央行政策、半导体/互联网产业周期与韩国交易制度;不要套用 A 股涨跌停、北向资金、龙虎榜、融资融券等 A 股专属概念。"
),
"en": (
"- This analysis covers a **Korea stock** (KOSPI/KOSDAQ suffix `.KS` / `.KQ`).\n"
"- Use Korea-market context: KRW FX, Bank of Korea policy, semiconductor/internet cycles, and local trading rules; do not apply China A-share concepts such as daily price-limit boards, Northbound flows, Dragon Tiger lists, or margin-financing narratives."
),
},
}

View File

@@ -518,7 +518,7 @@ class DecisionSignalService:
def _normalize_market(value: Any) -> str:
market = str(value or "").strip().lower()
if market not in VALID_MARKETS:
raise ValueError("market must be one of cn, hk, us")
raise ValueError("market must be one of cn, hk, us, jp, kr")
return market
@classmethod

View File

@@ -28,7 +28,7 @@ from src.services.run_diagnostics import sanitize_diagnostic_text
logger = logging.getLogger(__name__)
_ALLOWED_SOURCE_TYPES = {"rss", "atom", "newsnow"}
_ALLOWED_SCOPE_TYPES = {"symbol", "market", "sector"}
_ALLOWED_MARKETS = {"cn", "hk", "us", "global"}
_ALLOWED_MARKETS = {"cn", "hk", "us", "jp", "kr", "global"}
_PRIVATE_HOSTNAMES = {"localhost", "localhost.localdomain"}
_MAX_FEED_BYTES = 2 * 1024 * 1024
_MAX_FEED_REDIRECTS = 5

View File

@@ -29,7 +29,7 @@ except Exception: # pragma: no cover - optional dependency path
yf = None
EPS = 1e-8
VALID_MARKETS = {"cn", "hk", "us"}
VALID_MARKETS = {"cn", "hk", "us", "jp", "kr"}
VALID_COST_METHODS = {"fifo", "avg"}
VALID_SIDES = {"buy", "sell"}
VALID_CASH_DIRECTIONS = {"in", "out"}
@@ -1584,7 +1584,7 @@ class PortfolioService:
def _normalize_market(value: str) -> str:
market = (value or "").strip().lower()
if market not in VALID_MARKETS:
raise ValueError("market must be one of: cn, hk, us")
raise ValueError("market must be one of: cn, hk, us, jp, kr")
return market
@staticmethod

View File

@@ -27,8 +27,13 @@ _SUFFIX_DIGIT_LENS: dict = {
".SS": (6,),
".BJ": (6,),
".HK": (1, 2, 3, 4, 5),
".T": (4, 5),
".KS": (6,),
".KQ": (6,),
}
_PRESERVE_SUFFIXES = {".T", ".KS", ".KQ"}
def _valid_exchange_code(exchange: str, base: str, digit_lens: tuple[int, ...]) -> bool:
if not (base.isdigit() and len(base) in digit_lens):
@@ -90,6 +95,8 @@ def normalize_code(raw: str) -> Optional[str]:
return None
if text.isdigit() and len(text) in (5, 6):
return text
if any(text.endswith(suffix) for suffix in _PRESERVE_SUFFIXES):
return text if _strip_exchange_suffix(text) is not None else None
if re.match(r"^[A-Z]{1,5}(?:\.(?:US|[A-Z]))?$", text):
return text
stripped_suffix = _strip_exchange_suffix(text)

View File

@@ -246,7 +246,7 @@ def test_service_rejects_invalid_enums_and_ranges(isolated_db) -> None:
service = DecisionSignalService(db_manager=isolated_db)
with pytest.raises(ValueError, match="market"):
service.create_signal(_payload(market="jp"))
service.create_signal(_payload(market="global"))
with pytest.raises(ValueError, match="action"):
service.create_signal(_payload(action="strong buy"))
with pytest.raises(ValueError, match="confidence"):

View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
"""Regression tests for Issue #1718 JP/KR suffix-only market support."""
from unittest.mock import patch
import pandas as pd
from data_provider.base import BaseFetcher, DataFetchError, DataFetcherManager, normalize_stock_code
from data_provider.yfinance_fetcher import YfinanceFetcher
from src.core.trading_calendar import MARKET_EXCHANGE, MARKET_TIMEZONE, get_market_for_stock
from src.market_context import detect_market, get_market_guidelines
from src.services.stock_code_utils import is_code_like, normalize_code
class _FakeFetcher(BaseFetcher):
def __init__(self, name: str, should_fail: bool = False):
self.name = name
self.priority = 0 if name != "YfinanceFetcher" else 4
self.calls = []
self.should_fail = should_fail
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
raise NotImplementedError
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
raise NotImplementedError
def get_daily_data(self, stock_code, start_date=None, end_date=None, days=30):
self.calls.append(stock_code)
if self.should_fail:
raise DataFetchError(f"{self.name} should not be called for {stock_code}")
return pd.DataFrame(
{
"date": [pd.Timestamp("2026-06-18")],
"open": [1.0],
"high": [1.0],
"low": [1.0],
"close": [1.0],
"volume": [100],
"amount": [100.0],
"pct_chg": [0.0],
}
)
def test_normalize_and_detect_jp_kr_suffix_codes() -> None:
assert normalize_stock_code("7203.t") == "7203.T"
assert normalize_stock_code("005930.ks") == "005930.KS"
assert normalize_stock_code("035720.kq") == "035720.KQ"
assert detect_market("7203.T") == "jp"
assert detect_market("6758.T") == "jp"
assert detect_market("005930.KS") == "kr"
assert detect_market("035720.KQ") == "kr"
assert detect_market("005930") == "cn"
assert get_market_for_stock("7203.T") == "jp"
assert get_market_for_stock("005930.KS") == "kr"
assert get_market_for_stock("005930") == "cn"
assert is_code_like("7203.T") is True
assert is_code_like("005930.KS") is True
assert normalize_code("035720.KQ") == "035720.KQ"
def test_market_guidelines_for_jp_kr_exclude_a_share_specific_context() -> None:
jp_guidelines = get_market_guidelines("7203.T")
kr_guidelines = get_market_guidelines("005930.KS")
assert "日股" in jp_guidelines
assert "韩股" in kr_guidelines
for text in (jp_guidelines, kr_guidelines):
assert "不要套用 A 股" in text
assert "北向资金" in text
assert "龙虎榜" in text
def test_yfinance_keeps_jp_kr_suffix_codes_and_indices() -> None:
fetcher = YfinanceFetcher()
assert fetcher._convert_stock_code("7203.T") == "7203.T"
assert fetcher._convert_stock_code("005930.KS") == "005930.KS"
assert fetcher._convert_stock_code("035720.KQ") == "035720.KQ"
captured = []
def fake_fetch(_yf, yf_code, name, return_code):
captured.append((yf_code, name, return_code))
return {"code": return_code, "name": name, "current": 1.0}
fetcher._fetch_yf_ticker_data = fake_fetch # type: ignore[method-assign]
jp_indices = fetcher.get_main_indices("jp") or []
kr_indices = fetcher.get_main_indices("kr") or []
assert {item["code"] for item in jp_indices} == {"N225", "TOPX"}
assert {item["code"] for item in kr_indices} == {"KS11", "KQ11"}
assert ("^N225", "日经225", "N225") in captured
assert ("^TOPX", "东证指数", "TOPX") in captured
assert ("^KS11", "KOSPI", "KS11") in captured
assert ("^KQ11", "KOSDAQ", "KQ11") in captured
def test_data_fetcher_manager_routes_jp_kr_daily_only_to_yfinance() -> None:
efinance = _FakeFetcher("EfinanceFetcher", should_fail=True)
akshare = _FakeFetcher("AkshareFetcher", should_fail=True)
yfinance = _FakeFetcher("YfinanceFetcher")
manager = DataFetcherManager(fetchers=[efinance, akshare, yfinance])
with patch("data_provider.base.record_provider_run_started"), patch("data_provider.base.record_provider_run"):
jp_df, jp_source = manager.get_daily_data("7203.T")
kr_df, kr_source = manager.get_daily_data("005930.KS")
assert jp_source == "YfinanceFetcher"
assert kr_source == "YfinanceFetcher"
assert not jp_df.empty and not kr_df.empty
assert efinance.calls == []
assert akshare.calls == []
assert yfinance.calls == ["7203.T", "005930.KS"]
def test_trading_calendar_registers_jp_kr_exchanges_and_timezones() -> None:
assert MARKET_EXCHANGE["jp"] == "XTKS"
assert MARKET_EXCHANGE["kr"] == "XKRX"
assert MARKET_TIMEZONE["jp"] == "Asia/Tokyo"
assert MARKET_TIMEZONE["kr"] == "Asia/Seoul"