mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: 支持日本/韩国 suffix-only 个股分析 (#1720)
* feat: support JP/KR suffix stock analysis * fix(review-feedback-1720): 解决冲突后再合入
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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 ? '创建中...' : '创建账户'}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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')}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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` 双字段,避免旧合约或缓存只提供其中一个字段时概念股详情报缺失字段。
|
||||
|
||||
@@ -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
32
docs/market-support.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 市场支持与边界
|
||||
|
||||
## 日本/韩国个股 suffix-only MVP(Issue #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` 与既有配置兼容性保持不变。
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
125
tests/test_jp_kr_market_support.py
Normal file
125
tests/test_jp_kr_market_support.py
Normal 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"
|
||||
Reference in New Issue
Block a user