mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat(market): bring tw to first-class on decision-signal / portfolio / intelligence (service + API + frontend) (#1801)
Follow-up to the #1773 data-layer MVP (Taiwan suffix-only detection + routing,
merged in 2086e3c). That MVP deferred the service/API/frontend layers, leaving a
live defect: tw was absent from the DecisionSignal/Portfolio service VALID_MARKETS,
so _normalize_market("tw") raised ValueError on the decision-signal write path.
The analysis pipeline auto-extracts a DecisionSignal after history save
(_extract_decision_signal_after_history_save), so every tw analysis silently
failed to persist a signal while jp/kr succeeded -- tw was the only
yfinance-supported market that could be analyzed but never produced a signal.
Converge the tw market contract for DecisionSignal + Portfolio + Intelligence in
one pass (mirroring jp/kr #1720), per the human review on #1801 asking not to
land it piecemeal:
Backend service + API:
- src/services/{portfolio,intelligence}_service.py: VALID_MARKETS /
_ALLOWED_MARKETS + _normalize_market error strings accept tw
- src/services/decision_signal_service.py: _normalize_market error string
(VALID_MARKETS is imported from portfolio_service, so the set change propagates)
- src/services/decision_signal_extractor.py: drop the now-stale "(e.g. tw)" guard
comment (tw is supported; the guard still protects genuinely-unsupported markets)
- api/v1/schemas/{decision_signals,intelligence,portfolio}.py: Pydantic Literals + tw
- api/v1/endpoints/decision_signals.py + docs/architecture/api_spec.json: market
filter description + DecisionSignalMarket enum gain tw; test_api_schema_pydantic
exact-match vs create_app().openapi() passes (api_spec kept CRLF)
Frontend (DecisionSignal + Portfolio typed consumers only; tsc + vitest pass):
- apps/dsa-web/src/types/{decisionSignals,portfolio}.ts + pages/{DecisionSignalsPage,
PortfolioPage}.tsx + utils/{decisionSignalLabels,stockCode}.ts + i18n/uiText.ts:
add tw to the DecisionSignalMarket / portfolio market unions, the market filter
options, the tw display label, and .TW/.TWO stock-code normalization
- the alert Market-Light surface (types/alerts.ts MarketRegion, featureText
ALERT_MARKET_REGION_*) is intentionally LEFT OUT: the backend market_light_service
is cn/hk/us only, so exposing tw there would be a front/back mismatch
Tests:
- flip the two #1773 graceful-skip regressions to first-class assertions and add
test_extract_and_persist_writes_tw_signal (end-to-end persist guard)
- frontend: PortfolioPage + stockCode vitest gain tw cases
Docs (reconcile the tw contract so changelog/topic docs/code state one fact):
- docs/CHANGELOG.md: rewrite the #1772 [Unreleased] entries so they no longer say
"service/API deferred" + "tw gracefully skipped" alongside "tw now supported"
- docs/market-support.md, docs/decision-signals.md, docs/intelligence-sources.md:
sync the tw market enum / filter / examples; keep the boundary note
Still deferred (separate follow-ups): the Taiwan stock-index/seed + Web autocomplete,
and the alert (大盘红绿灯) Market-Light tw support (needs a market_light backend change).
Refs #1772
This commit is contained in:
@@ -123,7 +123,7 @@ def create_signal(request: DecisionSignalCreateRequest) -> DecisionSignalMutatio
|
|||||||
operation_id="listDecisionSignals",
|
operation_id="listDecisionSignals",
|
||||||
)
|
)
|
||||||
def list_signals(
|
def list_signals(
|
||||||
market: Optional[str] = Query(None, description="Optional market filter: cn/hk/us/jp/kr"),
|
market: Optional[str] = Query(None, description="Optional market filter: cn/hk/us/jp/kr/tw"),
|
||||||
stock_code: Optional[str] = Query(None, description="Optional stock code filter"),
|
stock_code: Optional[str] = Query(None, description="Optional stock code filter"),
|
||||||
action: Optional[str] = Query(None, description="Optional decision action filter"),
|
action: Optional[str] = Query(None, description="Optional decision action filter"),
|
||||||
market_phase: Optional[str] = Query(None, description="Optional market phase filter"),
|
market_phase: Optional[str] = Query(None, description="Optional market phase filter"),
|
||||||
@@ -306,7 +306,7 @@ def get_outcome_stats(
|
|||||||
)
|
)
|
||||||
def get_latest_active(
|
def get_latest_active(
|
||||||
stock_code: str,
|
stock_code: str,
|
||||||
market: Optional[str] = Query(None, description="Optional market filter: cn/hk/us/jp/kr"),
|
market: Optional[str] = Query(None, description="Optional market filter: cn/hk/us/jp/kr/tw"),
|
||||||
limit: int = Query(1, ge=1, le=100),
|
limit: int = Query(1, ge=1, le=100),
|
||||||
) -> DecisionSignalListResponse:
|
) -> DecisionSignalListResponse:
|
||||||
service = DecisionSignalService()
|
service = DecisionSignalService()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ DecisionSignalSourceType = Literal["analysis", "agent", "alert", "market_review"
|
|||||||
DecisionSignalStatus = Literal["active", "expired", "invalidated", "closed", "archived"]
|
DecisionSignalStatus = Literal["active", "expired", "invalidated", "closed", "archived"]
|
||||||
DecisionSignalPlanQuality = Literal["complete", "partial", "minimal", "unknown"]
|
DecisionSignalPlanQuality = Literal["complete", "partial", "minimal", "unknown"]
|
||||||
DecisionSignalHorizon = Literal["intraday", "1d", "3d", "5d", "10d", "swing", "long"]
|
DecisionSignalHorizon = Literal["intraday", "1d", "3d", "5d", "10d", "swing", "long"]
|
||||||
DecisionSignalMarket = Literal["cn", "hk", "us", "jp", "kr"]
|
DecisionSignalMarket = Literal["cn", "hk", "us", "jp", "kr", "tw"]
|
||||||
DecisionSignalOutcomeStatus = Literal["completed", "unable"]
|
DecisionSignalOutcomeStatus = Literal["completed", "unable"]
|
||||||
DecisionSignalOutcomeValue = Literal["hit", "miss", "neutral"]
|
DecisionSignalOutcomeValue = Literal["hit", "miss", "neutral"]
|
||||||
DecisionSignalFeedbackValue = Literal["useful", "not_useful"]
|
DecisionSignalFeedbackValue = Literal["useful", "not_useful"]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
SourceTypeValue = Literal["rss", "atom", "newsnow"]
|
SourceTypeValue = Literal["rss", "atom", "newsnow"]
|
||||||
ScopeTypeValue = Literal["symbol", "market", "sector"]
|
ScopeTypeValue = Literal["symbol", "market", "sector"]
|
||||||
MarketValue = Literal["cn", "hk", "us", "jp", "kr", "global"]
|
MarketValue = Literal["cn", "hk", "us", "jp", "kr", "tw", "global"]
|
||||||
|
|
||||||
|
|
||||||
class IntelligenceSourceCreateRequest(BaseModel):
|
class IntelligenceSourceCreateRequest(BaseModel):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field
|
|||||||
class PortfolioAccountCreateRequest(BaseModel):
|
class PortfolioAccountCreateRequest(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=64)
|
name: str = Field(..., min_length=1, max_length=64)
|
||||||
broker: Optional[str] = Field(None, max_length=64)
|
broker: Optional[str] = Field(None, max_length=64)
|
||||||
market: Literal["cn", "hk", "us", "jp", "kr"] = "cn"
|
market: Literal["cn", "hk", "us", "jp", "kr", "tw"] = "cn"
|
||||||
base_currency: str = Field("CNY", min_length=3, max_length=8)
|
base_currency: str = Field("CNY", min_length=3, max_length=8)
|
||||||
owner_id: Optional[str] = Field(None, max_length=64)
|
owner_id: Optional[str] = Field(None, max_length=64)
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ class PortfolioAccountCreateRequest(BaseModel):
|
|||||||
class PortfolioAccountUpdateRequest(BaseModel):
|
class PortfolioAccountUpdateRequest(BaseModel):
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=64)
|
name: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||||
broker: Optional[str] = Field(None, max_length=64)
|
broker: Optional[str] = Field(None, max_length=64)
|
||||||
market: Optional[Literal["cn", "hk", "us", "jp", "kr"]] = None
|
market: Optional[Literal["cn", "hk", "us", "jp", "kr", "tw"]] = None
|
||||||
base_currency: Optional[str] = Field(None, min_length=3, max_length=8)
|
base_currency: Optional[str] = Field(None, min_length=3, max_length=8)
|
||||||
owner_id: Optional[str] = Field(None, max_length=64)
|
owner_id: Optional[str] = Field(None, max_length=64)
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
@@ -51,7 +51,7 @@ class PortfolioTradeCreateRequest(BaseModel):
|
|||||||
price: float = Field(..., gt=0)
|
price: float = Field(..., gt=0)
|
||||||
fee: float = Field(0.0, ge=0)
|
fee: float = Field(0.0, ge=0)
|
||||||
tax: float = Field(0.0, ge=0)
|
tax: float = Field(0.0, ge=0)
|
||||||
market: Optional[Literal["cn", "hk", "us", "jp", "kr"]] = None
|
market: Optional[Literal["cn", "hk", "us", "jp", "kr", "tw"]] = None
|
||||||
currency: Optional[str] = Field(None, min_length=3, max_length=8)
|
currency: Optional[str] = Field(None, min_length=3, max_length=8)
|
||||||
trade_uid: Optional[str] = Field(None, max_length=128)
|
trade_uid: Optional[str] = Field(None, max_length=128)
|
||||||
note: Optional[str] = Field(None, max_length=255)
|
note: Optional[str] = Field(None, max_length=255)
|
||||||
@@ -71,7 +71,7 @@ class PortfolioCorporateActionCreateRequest(BaseModel):
|
|||||||
symbol: str = Field(..., min_length=1, max_length=16)
|
symbol: str = Field(..., min_length=1, max_length=16)
|
||||||
effective_date: date
|
effective_date: date
|
||||||
action_type: Literal["cash_dividend", "split_adjustment"]
|
action_type: Literal["cash_dividend", "split_adjustment"]
|
||||||
market: Optional[Literal["cn", "hk", "us", "jp", "kr"]] = None
|
market: Optional[Literal["cn", "hk", "us", "jp", "kr", "tw"]] = None
|
||||||
currency: Optional[str] = Field(None, min_length=3, max_length=8)
|
currency: Optional[str] = Field(None, min_length=3, max_length=8)
|
||||||
cash_dividend_per_share: Optional[float] = Field(None, ge=0)
|
cash_dividend_per_share: Optional[float] = Field(None, ge=0)
|
||||||
split_ratio: Optional[float] = Field(None, gt=0)
|
split_ratio: Optional[float] = Field(None, gt=0)
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ const zh = {
|
|||||||
'decisionSignals.market.hk': '港股',
|
'decisionSignals.market.hk': '港股',
|
||||||
'decisionSignals.market.jp': '日股',
|
'decisionSignals.market.jp': '日股',
|
||||||
'decisionSignals.market.kr': '韩股',
|
'decisionSignals.market.kr': '韩股',
|
||||||
|
'decisionSignals.market.tw': '台股',
|
||||||
'decisionSignals.market.us': '美股',
|
'decisionSignals.market.us': '美股',
|
||||||
'decisionSignals.marketPhase': '阶段',
|
'decisionSignals.marketPhase': '阶段',
|
||||||
'decisionSignals.marketPhase.closing_auction': '集合竞价',
|
'decisionSignals.marketPhase.closing_auction': '集合竞价',
|
||||||
@@ -965,6 +966,7 @@ const en: Record<UiTextKey, string> = {
|
|||||||
'decisionSignals.market.hk': 'Hong Kong',
|
'decisionSignals.market.hk': 'Hong Kong',
|
||||||
'decisionSignals.market.jp': 'Japan',
|
'decisionSignals.market.jp': 'Japan',
|
||||||
'decisionSignals.market.kr': 'Korea',
|
'decisionSignals.market.kr': 'Korea',
|
||||||
|
'decisionSignals.market.tw': 'Taiwan',
|
||||||
'decisionSignals.market.us': 'US',
|
'decisionSignals.market.us': 'US',
|
||||||
'decisionSignals.marketPhase': 'Phase',
|
'decisionSignals.marketPhase': 'Phase',
|
||||||
'decisionSignals.marketPhase.closing_auction': 'Closing auction',
|
'decisionSignals.marketPhase.closing_auction': 'Closing auction',
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ type SelectedSignal = {
|
|||||||
source: 'list' | 'latest';
|
source: 'list' | 'latest';
|
||||||
};
|
};
|
||||||
|
|
||||||
const MARKET_OPTIONS: DecisionSignalMarket[] = ['cn', 'hk', 'us', 'jp', 'kr'];
|
const MARKET_OPTIONS: DecisionSignalMarket[] = ['cn', 'hk', 'us', 'jp', 'kr', 'tw'];
|
||||||
const ACTION_OPTIONS: DecisionAction[] = ['buy', 'add', 'hold', 'reduce', 'sell', 'watch', 'avoid', 'alert'];
|
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 PHASE_OPTIONS: MarketPhaseValue[] = ['premarket', 'intraday', 'lunch_break', 'closing_auction', 'postmarket', 'non_trading', 'unknown'];
|
||||||
const SOURCE_OPTIONS: DecisionSignalSourceType[] = ['analysis', 'agent', 'alert', 'market_review', 'manual'];
|
const SOURCE_OPTIONS: DecisionSignalSourceType[] = ['analysis', 'agent', 'alert', 'market_review', 'manual'];
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ function isNewerSignal(left: DecisionSignalItem | undefined, right: DecisionSign
|
|||||||
return getSignalTime(right) > getSignalTime(left);
|
return getSignalTime(right) > getSignalTime(left);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DECISION_SIGNAL_MARKETS = new Set<DecisionSignalMarket>(['cn', 'hk', 'us', 'jp', 'kr']);
|
const DECISION_SIGNAL_MARKETS = new Set<DecisionSignalMarket>(['cn', 'hk', 'us', 'jp', 'kr', 'tw']);
|
||||||
type PortfolioAccountMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr';
|
type PortfolioAccountMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
|
||||||
|
|
||||||
function toDecisionSignalMarket(value: string | null | undefined): DecisionSignalMarket | undefined {
|
function toDecisionSignalMarket(value: string | null | undefined): DecisionSignalMarket | undefined {
|
||||||
const normalized = String(value || '').toLowerCase();
|
const normalized = String(value || '').toLowerCase();
|
||||||
@@ -1092,6 +1092,7 @@ const PortfolioPage: React.FC = () => {
|
|||||||
<option value="us">市场:美股(us)</option>
|
<option value="us">市场:美股(us)</option>
|
||||||
<option value="jp">市场:日股(jp)</option>
|
<option value="jp">市场:日股(jp)</option>
|
||||||
<option value="kr">市场:韩股(kr)</option>
|
<option value="kr">市场:韩股(kr)</option>
|
||||||
|
<option value="tw">市场:台股(tw)</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" className="btn-secondary text-sm" disabled={accountCreating}>
|
<button type="submit" className="btn-secondary text-sm" disabled={accountCreating}>
|
||||||
{accountCreating ? '创建中...' : '创建账户'}
|
{accountCreating ? '创建中...' : '创建账户'}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ vi.mock('recharts', () => ({
|
|||||||
type AccountItem = {
|
type AccountItem = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
|
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
|
||||||
baseCurrency?: string;
|
baseCurrency?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type DecisionSignalSourceType = 'analysis' | 'agent' | 'alert' | 'market_
|
|||||||
export type DecisionSignalStatus = 'active' | 'expired' | 'invalidated' | 'closed' | 'archived';
|
export type DecisionSignalStatus = 'active' | 'expired' | 'invalidated' | 'closed' | 'archived';
|
||||||
export type DecisionSignalPlanQuality = 'complete' | 'partial' | 'minimal' | 'unknown';
|
export type DecisionSignalPlanQuality = 'complete' | 'partial' | 'minimal' | 'unknown';
|
||||||
export type DecisionSignalHorizon = 'intraday' | '1d' | '3d' | '5d' | '10d' | 'swing' | 'long';
|
export type DecisionSignalHorizon = 'intraday' | '1d' | '3d' | '5d' | '10d' | 'swing' | 'long';
|
||||||
export type DecisionSignalMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr';
|
export type DecisionSignalMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
|
||||||
export type DecisionSignalOutcomeEvalStatus = 'completed' | 'unable';
|
export type DecisionSignalOutcomeEvalStatus = 'completed' | 'unable';
|
||||||
export type DecisionSignalOutcomeValue = 'hit' | 'miss' | 'neutral';
|
export type DecisionSignalOutcomeValue = 'hit' | 'miss' | 'neutral';
|
||||||
export type DecisionSignalFeedbackValue = 'useful' | 'not_useful';
|
export type DecisionSignalFeedbackValue = 'useful' | 'not_useful';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface PortfolioAccountItem {
|
|||||||
ownerId?: string | null;
|
ownerId?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
broker?: string | null;
|
broker?: string | null;
|
||||||
market: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
|
market: 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
@@ -24,7 +24,7 @@ export interface PortfolioAccountListResponse {
|
|||||||
export interface PortfolioAccountCreateRequest {
|
export interface PortfolioAccountCreateRequest {
|
||||||
name: string;
|
name: string;
|
||||||
broker?: string;
|
broker?: string;
|
||||||
market: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
|
market: 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ export interface PortfolioTradeCreateRequest {
|
|||||||
price: number;
|
price: number;
|
||||||
fee?: number;
|
fee?: number;
|
||||||
tax?: number;
|
tax?: number;
|
||||||
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
|
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
|
||||||
currency?: string;
|
currency?: string;
|
||||||
tradeUid?: string;
|
tradeUid?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
@@ -201,7 +201,7 @@ export interface PortfolioCorporateActionCreateRequest {
|
|||||||
symbol: string;
|
symbol: string;
|
||||||
effectiveDate: string;
|
effectiveDate: string;
|
||||||
actionType: PortfolioCorporateActionType;
|
actionType: PortfolioCorporateActionType;
|
||||||
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr';
|
market?: 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
|
||||||
currency?: string;
|
currency?: string;
|
||||||
cashDividendPerShare?: number;
|
cashDividendPerShare?: number;
|
||||||
splitRatio?: number;
|
splitRatio?: number;
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ describe('normalizeStockCode', () => {
|
|||||||
expect(normalizeStockCode('005930')).toBe('005930');
|
expect(normalizeStockCode('005930')).toBe('005930');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps TW Yahoo suffix codes (.TW / .TWO) in canonical uppercase suffix form', () => {
|
||||||
|
expect(normalizeStockCode('2330.tw')).toBe('2330.TW');
|
||||||
|
expect(normalizeStockCode('0050.TW')).toBe('0050.TW');
|
||||||
|
expect(normalizeStockCode('006208.tw')).toBe('006208.TW');
|
||||||
|
expect(normalizeStockCode('6505.two')).toBe('6505.TWO');
|
||||||
|
expect(normalizeStockCode('2330')).toBe('2330');
|
||||||
|
});
|
||||||
|
|
||||||
it('is case-insensitive for prefixes', () => {
|
it('is case-insensitive for prefixes', () => {
|
||||||
expect(normalizeStockCode('sh600519')).toBe('600519');
|
expect(normalizeStockCode('sh600519')).toBe('600519');
|
||||||
expect(normalizeStockCode('sz000001')).toBe('000001');
|
expect(normalizeStockCode('sz000001')).toBe('000001');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const MARKET_LABEL_KEYS: Record<DecisionSignalMarket, UiTextKey> = {
|
|||||||
us: 'decisionSignals.market.us',
|
us: 'decisionSignals.market.us',
|
||||||
jp: 'decisionSignals.market.jp',
|
jp: 'decisionSignals.market.jp',
|
||||||
kr: 'decisionSignals.market.kr',
|
kr: 'decisionSignals.market.kr',
|
||||||
|
tw: 'decisionSignals.market.tw',
|
||||||
};
|
};
|
||||||
|
|
||||||
const MARKET_PHASE_LABEL_KEYS: Record<MarketPhaseValue, UiTextKey> = {
|
const MARKET_PHASE_LABEL_KEYS: Record<MarketPhaseValue, UiTextKey> = {
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ export function normalizeStockCode(stockCode: string): string {
|
|||||||
if ((suffix === 'KS' || suffix === 'KQ') && /^\d{6}$/.test(base)) {
|
if ((suffix === 'KS' || suffix === 'KQ') && /^\d{6}$/.test(base)) {
|
||||||
return `${base}.${suffix}`;
|
return `${base}.${suffix}`;
|
||||||
}
|
}
|
||||||
|
// TW Yahoo suffix-only codes (TWSE `.TW` / TPEx `.TWO`), base 4-6 digits.
|
||||||
|
if ((suffix === 'TW' || suffix === 'TWO') && /^\d{4,6}$/.test(base)) {
|
||||||
|
return `${base}.${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 00700.HK → HK00700
|
// 00700.HK → HK00700
|
||||||
if (suffix === 'HK' && /^\d{1,5}$/.test(base)) {
|
if (suffix === 'HK' && /^\d{1,5}$/.test(base)) {
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|||||||
- [改进] #1595 P1 新增 prompt cache telemetry / analysis-path hints / diagnostics 最小配置,默认不改变 provider 请求 shape,并复用 LLM usage HMAC secret 做 domain-separated cache hint 派生。
|
- [改进] #1595 P1 新增 prompt cache telemetry / analysis-path hints / diagnostics 最小配置,默认不改变 provider 请求 shape,并复用 LLM usage HMAC secret 做 domain-separated cache hint 派生。
|
||||||
- [改进] 将 Docker Compose 默认内存建议从 512M 提升到 1G,并补充低配部署说明。
|
- [改进] 将 Docker Compose 默认内存建议从 512M 提升到 1G,并补充低配部署说明。
|
||||||
- [改进] 每日分析 workflow 兼容误将 `STOCK_LIST` 配到同名 Environment variables 的场景,同时保留 Repository variables 作为推荐配置入口。
|
- [改进] 每日分析 workflow 兼容误将 `STOCK_LIST` 配到同名 Environment variables 的场景,同时保留 Repository variables 作为推荐配置入口。
|
||||||
- [新功能] #1772 新增台湾(台股)suffix-only 个股分析 MVP(**市场识别与数据路由层**):手输 `.TW`(TWSE 上市)/ `.TWO`(TPEx 上柜)代码可走 YFinance 日线与近实时行情,补充市场识别、交易日历(XTAI / Asia/Taipei)、Prompt 语义与能力边界文档;加权指数 `^TWII`、柜买指数 `^TWOII`。台股股票索引/种子、Web 自动补全、API 市场枚举与 Portfolio/DecisionSignal 服务层放行作为后续 PR。
|
- [新功能] #1772 新增台湾(台股)suffix-only 个股分析 MVP(**市场识别与数据路由层**):手输 `.TW`(TWSE 上市)/ `.TWO`(TPEx 上柜)代码可走 YFinance 日线与近实时行情,补充市场识别、交易日历(XTAI / Asia/Taipei)、Prompt 语义与能力边界文档;加权指数 `^TWII`、柜买指数 `^TWOII`。台股股票索引/种子、Web 自动补全与告警(大盘红绿灯)市场放行作为后续 PR。
|
||||||
- [文档] #1772 明确本次为台股 suffix 仅路由兼容改造,对齐 #1718 日韩模式;不涉及 provider/model/base URL/运行时配置变更;DecisionSignal 抽取对 `tw` 优雅跳过;回退方式为 revert 本次改动或移除 tw 入口恢复既有行为。
|
- [文档] #1772 明确本次为台股 suffix 仅路由兼容改造,对齐 #1718 日韩模式;不涉及 provider/model/base URL/运行时配置变更;回退方式为 revert 本次改动或移除 tw 入口恢复既有行为。
|
||||||
|
- [新功能] #1772 台股 `tw` 纳入 DecisionSignal / Portfolio / Intelligence 服务层与 API 市场枚举(VALID_MARKETS / _ALLOWED_MARKETS + Pydantic Literal + api_spec.json),修复数据层 MVP 下 tw 分析在 pipeline 自动抽取 DecisionSignal 时被 _normalize_market 静默丢弃的缺陷,并同步放行 DecisionSignal/Portfolio 前端市场类型与筛选及相关专题文档,对齐 #1720 日韩;告警(大盘红绿灯)市场仍为 cn/hk/us。
|
||||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||||
|
|
||||||
|
|||||||
@@ -874,10 +874,10 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Optional market filter: cn/hk/us/jp/kr",
|
"description": "Optional market filter: cn/hk/us/jp/kr/tw",
|
||||||
"title": "Market"
|
"title": "Market"
|
||||||
},
|
},
|
||||||
"description": "Optional market filter: cn/hk/us/jp/kr"
|
"description": "Optional market filter: cn/hk/us/jp/kr/tw"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "stock_code",
|
"name": "stock_code",
|
||||||
@@ -1239,10 +1239,10 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Optional market filter: cn/hk/us/jp/kr",
|
"description": "Optional market filter: cn/hk/us/jp/kr/tw",
|
||||||
"title": "Market"
|
"title": "Market"
|
||||||
},
|
},
|
||||||
"description": "Optional market filter: cn/hk/us/jp/kr"
|
"description": "Optional market filter: cn/hk/us/jp/kr/tw"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
@@ -3791,7 +3791,8 @@
|
|||||||
"hk",
|
"hk",
|
||||||
"us",
|
"us",
|
||||||
"jp",
|
"jp",
|
||||||
"kr"
|
"kr",
|
||||||
|
"tw"
|
||||||
],
|
],
|
||||||
"title": "Market"
|
"title": "Market"
|
||||||
},
|
},
|
||||||
@@ -5665,7 +5666,8 @@
|
|||||||
"hk",
|
"hk",
|
||||||
"us",
|
"us",
|
||||||
"jp",
|
"jp",
|
||||||
"kr"
|
"kr",
|
||||||
|
"tw"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
| 字段 | 取值 |
|
| 字段 | 取值 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `market` | `cn`、`hk`、`us`、`jp`、`kr` |
|
| `market` | `cn`、`hk`、`us`、`jp`、`kr`、`tw` |
|
||||||
| `source_type` | `analysis`、`agent`、`alert`、`market_review`、`manual` |
|
| `source_type` | `analysis`、`agent`、`alert`、`market_review`、`manual` |
|
||||||
| `market_phase` | `premarket`、`intraday`、`lunch_break`、`closing_auction`、`postmarket`、`non_trading`、`unknown` |
|
| `market_phase` | `premarket`、`intraday`、`lunch_break`、`closing_auction`、`postmarket`、`non_trading`、`unknown` |
|
||||||
| `action` | `buy`、`add`、`hold`、`reduce`、`sell`、`watch`、`avoid`、`alert` |
|
| `action` | `buy`、`add`、`hold`、`reduce`、`sell`、`watch`、`avoid`、`alert` |
|
||||||
@@ -65,7 +65,7 @@ Web 入口位于 `/decision-signals`:
|
|||||||
|
|
||||||
- 默认查询 `status=active`。
|
- 默认查询 `status=active`。
|
||||||
- 支持按市场、股票代码、动作、市场阶段、来源、来源报告 ID 和状态筛选。
|
- 支持按市场、股票代码、动作、市场阶段、来源、来源报告 ID 和状态筛选。
|
||||||
- market filter 已包含 `cn/hk/us/jp/kr`,P7 只补齐 `jp/kr` 的本地化标签,不改变筛选范围。
|
- market filter 在 API / 服务层与 Web 前端均已支持 `cn/hk/us/jp/kr/tw`;`jp/kr/tw` 的前端本地化标签均已补齐,`tw` 信号可经 API 正常写入、按 `market=tw` 查询,并可在 Web DecisionSignal 页面通过市场筛选项选择台股(tw);告警(大盘红绿灯)市场仍为 cn/hk/us。
|
||||||
- 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。
|
- 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。
|
||||||
- Web 只能把信号标记为 `closed`、`invalidated` 或 `archived`,不提供 terminal 状态恢复为 active。
|
- Web 只能把信号标记为 `closed`、`invalidated` 或 `archived`,不提供 terminal 状态恢复为 active。
|
||||||
- 历史报告详情不再内嵌展示报告绑定的 `source_type=analysis` 信号,也不会因打开报告详情触发 `source_report_id` 信号查询;需要查看报告来源信号时统一进入 `/decision-signals` 页面按来源报告 ID 精确筛选,或打开 `/decision-signals?sourceReportId=<recordId>` deep link。该筛选和 deep link 都会使用 `source_type=analysis + source_report_id` 的精确查询,以保留旧报告的 best-effort 懒回填入口。
|
- 历史报告详情不再内嵌展示报告绑定的 `source_type=analysis` 信号,也不会因打开报告详情触发 `source_report_id` 信号查询;需要查看报告来源信号时统一进入 `/decision-signals` 页面按来源报告 ID 精确筛选,或打开 `/decision-signals?sourceReportId=<recordId>` deep link。该筛选和 deep link 都会使用 `source_type=analysis + source_report_id` 的精确查询,以保留旧报告的 best-effort 懒回填入口。
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Issue #1707 的首版能力聚焦“合规资讯源采集、本地沉淀、可
|
|||||||
- 保存资讯源配置、启用状态、作用域和最近一次拉取状态。
|
- 保存资讯源配置、启用状态、作用域和最近一次拉取状态。
|
||||||
- 拉取条目落库到 `intelligence_items`,保存标题、摘要、URL、来源、发布时间、拉取时间、市场与作用域。
|
- 拉取条目落库到 `intelligence_items`,保存标题、摘要、URL、来源、发布时间、拉取时间、市场与作用域。
|
||||||
- 按 URL 去重;无 URL 条目使用 `no-url:intel:<hash>` 兜底键。
|
- 按 URL 去重;无 URL 条目使用 `no-url:intel:<hash>` 兜底键。
|
||||||
- 支持 `symbol` / `market` / `sector` 作用域,以及 `cn` / `hk` / `us` / `global` 市场标记。
|
- 支持 `symbol` / `market` / `sector` 作用域,以及 `cn` / `hk` / `us` / `jp` / `kr` / `tw` / `global` 市场标记。
|
||||||
- 拉取批处理采用 fail-open:单个源失败不会阻塞其他源或主分析链路。
|
- 拉取批处理采用 fail-open:单个源失败不会阻塞其他源或主分析链路。
|
||||||
- 支持 retention 清理,避免资讯池无限增长。
|
- 支持 retention 清理,避免资讯池无限增长。
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
## 台湾个股 suffix-only MVP(Issue #1772,Refs #1772)
|
## 台湾个股 suffix-only MVP(Issue #1772,Refs #1772)
|
||||||
|
|
||||||
当前阶段支持手动输入台湾股票的 Yahoo Finance 后缀代码,进入既有个股分析、历史保存和基础报告展示链路。TWSE 上市股票使用 `.TW` 后缀,TPEx 上柜(柜买)股票使用 `.TWO` 后缀,二者折叠为同一 `tw` 市场标签。**本次仅覆盖市场识别(detection)与数据路由层**;台股股票索引/种子、Web 自动补全、前端市场类型、API 市场枚举与 Portfolio/DecisionSignal 服务层均作为后续 PR。对齐 #1718 日韩 MVP 模式。
|
当前阶段支持手动输入台湾股票的 Yahoo Finance 后缀代码,进入既有个股分析、历史保存和基础报告展示链路。TWSE 上市股票使用 `.TW` 后缀,TPEx 上柜(柜买)股票使用 `.TWO` 后缀,二者折叠为同一 `tw` 市场标签。**本次覆盖市场识别(detection)、数据路由层、DecisionSignal/Portfolio/Intelligence 服务层与 API 市场枚举,以及 DecisionSignal/Portfolio 前端市场类型与筛选**;台股股票索引/种子、Web 自动补全与告警(大盘红绿灯)市场放行仍作为后续 PR。对齐 #1718 日韩 MVP 模式。
|
||||||
|
|
||||||
支持格式:
|
支持格式:
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
- 不承诺实时行情;Yahoo Finance 数据可能延迟或字段缺失。
|
- 不承诺实时行情;Yahoo Finance 数据可能延迟或字段缺失。
|
||||||
- 不承诺完整基本面、行业/板块、市场宽度、涨跌家数或台股大盘复盘。
|
- 不承诺完整基本面、行业/板块、市场宽度、涨跌家数或台股大盘复盘。
|
||||||
- 本次未包含台股股票索引/种子、Web 自动补全、前端市场类型、API 市场枚举与 Portfolio/DecisionSignal 服务层放行,均作为后续 PR;数据层已识别 `tw`,DecisionSignal 抽取对 `tw` 优雅跳过(不产出信号、也不报错或刷 traceback)。
|
- 台股股票索引/种子、Web 自动补全与告警(大盘红绿灯)市场放行仍作为后续 PR;告警 MarketRegion 与后端 market_light 仍为 cn/hk/us,未含 tw。
|
||||||
- 不补齐 Portfolio 的 TWD 汇率、成本、市值完整口径(属上述后续 PR 范围)。
|
- 不补齐 Portfolio 的 TWD 汇率、成本、市值完整口径(属上述后续 PR 范围)。
|
||||||
|
|
||||||
回滚方式:移除 `tw` 市场识别、交易日历注册与 YFinance 路由扩展,并删除本文档中的能力声明。
|
回滚方式:移除 `tw` 市场识别、交易日历注册、YFinance 路由扩展与服务层/API 市场枚举及前端市场类型放行,并删除本文档中的能力声明。
|
||||||
|
|||||||
@@ -61,9 +61,10 @@ def build_decision_signal_payload_from_report(
|
|||||||
logger.warning("Skip decision signal extraction: unrecognized market stock_code=%s", raw_code)
|
logger.warning("Skip decision signal extraction: unrecognized market stock_code=%s", raw_code)
|
||||||
return None
|
return None
|
||||||
if market not in VALID_MARKETS:
|
if market not in VALID_MARKETS:
|
||||||
# A market the data layer recognizes (e.g. tw) but the decision-signal
|
# A market the data layer recognizes but the decision-signal service
|
||||||
# service layer does not yet support. Skip gracefully instead of letting
|
# layer does not accept (e.g. a market added to detection ahead of
|
||||||
# create_signal raise a swallowed ValueError + noisy traceback.
|
# VALID_MARKETS). Skip gracefully instead of letting create_signal
|
||||||
|
# raise a swallowed ValueError + noisy traceback.
|
||||||
logger.info(
|
logger.info(
|
||||||
"Skip decision signal extraction: market=%s not yet wired for signals stock_code=%s",
|
"Skip decision signal extraction: market=%s not yet wired for signals stock_code=%s",
|
||||||
market,
|
market,
|
||||||
|
|||||||
@@ -866,7 +866,7 @@ class DecisionSignalService:
|
|||||||
def _normalize_market(value: Any) -> str:
|
def _normalize_market(value: Any) -> str:
|
||||||
market = str(value or "").strip().lower()
|
market = str(value or "").strip().lower()
|
||||||
if market not in VALID_MARKETS:
|
if market not in VALID_MARKETS:
|
||||||
raise ValueError("market must be one of cn, hk, us, jp, kr")
|
raise ValueError("market must be one of cn, hk, us, jp, kr, tw")
|
||||||
return market
|
return market
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from src.services.run_diagnostics import sanitize_diagnostic_text
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_ALLOWED_SOURCE_TYPES = {"rss", "atom", "newsnow"}
|
_ALLOWED_SOURCE_TYPES = {"rss", "atom", "newsnow"}
|
||||||
_ALLOWED_SCOPE_TYPES = {"symbol", "market", "sector"}
|
_ALLOWED_SCOPE_TYPES = {"symbol", "market", "sector"}
|
||||||
_ALLOWED_MARKETS = {"cn", "hk", "us", "jp", "kr", "global"}
|
_ALLOWED_MARKETS = {"cn", "hk", "us", "jp", "kr", "tw", "global"}
|
||||||
_PRIVATE_HOSTNAMES = {"localhost", "localhost.localdomain"}
|
_PRIVATE_HOSTNAMES = {"localhost", "localhost.localdomain"}
|
||||||
_MAX_FEED_BYTES = 2 * 1024 * 1024
|
_MAX_FEED_BYTES = 2 * 1024 * 1024
|
||||||
_MAX_FEED_REDIRECTS = 5
|
_MAX_FEED_REDIRECTS = 5
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ except Exception: # pragma: no cover - optional dependency path
|
|||||||
yf = None
|
yf = None
|
||||||
|
|
||||||
EPS = 1e-8
|
EPS = 1e-8
|
||||||
VALID_MARKETS = {"cn", "hk", "us", "jp", "kr"}
|
VALID_MARKETS = {"cn", "hk", "us", "jp", "kr", "tw"}
|
||||||
VALID_COST_METHODS = {"fifo", "avg"}
|
VALID_COST_METHODS = {"fifo", "avg"}
|
||||||
VALID_SIDES = {"buy", "sell"}
|
VALID_SIDES = {"buy", "sell"}
|
||||||
VALID_CASH_DIRECTIONS = {"in", "out"}
|
VALID_CASH_DIRECTIONS = {"in", "out"}
|
||||||
@@ -1584,7 +1584,7 @@ class PortfolioService:
|
|||||||
def _normalize_market(value: str) -> str:
|
def _normalize_market(value: str) -> str:
|
||||||
market = (value or "").strip().lower()
|
market = (value or "").strip().lower()
|
||||||
if market not in VALID_MARKETS:
|
if market not in VALID_MARKETS:
|
||||||
raise ValueError("market must be one of: cn, hk, us, jp, kr")
|
raise ValueError("market must be one of: cn, hk, us, jp, kr, tw")
|
||||||
return market
|
return market
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ def test_decision_signal_topic_references_live_api_schema_and_docs() -> None:
|
|||||||
if parameter["name"] == "market"
|
if parameter["name"] == "market"
|
||||||
]
|
]
|
||||||
assert market_descriptions == [
|
assert market_descriptions == [
|
||||||
"Optional market filter: cn/hk/us/jp/kr",
|
"Optional market filter: cn/hk/us/jp/kr/tw",
|
||||||
"Optional market filter: cn/hk/us/jp/kr",
|
"Optional market filter: cn/hk/us/jp/kr/tw",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,14 +72,14 @@ def _result(**overrides) -> AnalysisResult:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def test_build_payload_skips_tw_market_gracefully() -> None:
|
def test_build_payload_includes_tw_market() -> None:
|
||||||
"""A Taiwan (`tw`) stock is recognized by the data layer but is intentionally
|
"""A Taiwan (`tw`) stock is now first-class on the DecisionSignal write path
|
||||||
not yet wired into the DecisionSignal service layer (a deferred follow-up).
|
(service VALID_MARKETS accepts tw, matching jp/kr).
|
||||||
|
|
||||||
The payload builder must SKIP it (return None) without raising — locking the
|
Regression guard for the data-layer MVP follow-up: the analysis pipeline
|
||||||
"no swallowed ValueError + noisy traceback on every tw analysis" behavior the
|
auto-extracts a DecisionSignal after history save, so tw must PRODUCE a
|
||||||
data-layer MVP relies on. A plain action ("buy") is set so the skip is the
|
payload (market == "tw") rather than be silently dropped by _normalize_market.
|
||||||
market guard, not the earlier no-action early-return.
|
A plain action ("buy") is set so the path reaches the market mapping.
|
||||||
"""
|
"""
|
||||||
result = _result(code="2330.TW", name="台积电")
|
result = _result(code="2330.TW", name="台积电")
|
||||||
|
|
||||||
@@ -93,7 +93,9 @@ def test_build_payload_skips_tw_market_gracefully() -> None:
|
|||||||
report_type="full",
|
report_type="full",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert payload is None
|
assert payload is not None
|
||||||
|
assert payload["market"] == "tw"
|
||||||
|
assert payload["action"] == "buy"
|
||||||
|
|
||||||
|
|
||||||
def test_build_payload_maps_report_context_and_price_plan() -> None:
|
def test_build_payload_maps_report_context_and_price_plan() -> None:
|
||||||
@@ -333,6 +335,37 @@ def test_extract_and_persist_reuses_service_dedup_and_sanitization(isolated_db)
|
|||||||
assert persisted["entry_high"] == 1700.0
|
assert persisted["entry_high"] == 1700.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_and_persist_writes_tw_signal(isolated_db) -> None:
|
||||||
|
"""End-to-end write-leg guard: a tw analysis must PERSIST a DecisionSignal
|
||||||
|
through create_signal -> _normalize_market -> DB, not merely build the payload.
|
||||||
|
|
||||||
|
Closes the silent-failure leg where _normalize_market("tw") raised ValueError
|
||||||
|
inside extract_and_persist and was swallowed by its broad except -> return None,
|
||||||
|
so every tw analysis produced no signal while jp/kr did.
|
||||||
|
"""
|
||||||
|
service = DecisionSignalService(db_manager=isolated_db)
|
||||||
|
result = _result(code="2330.TW", name="台积电")
|
||||||
|
|
||||||
|
created = extract_and_persist_from_analysis_result(
|
||||||
|
result,
|
||||||
|
context_snapshot={"market_phase_summary": {"phase": "intraday"}},
|
||||||
|
portfolio_context={"quantity": 10},
|
||||||
|
source_report_id=2330,
|
||||||
|
trace_id="trace-tw-persist",
|
||||||
|
query_source="api",
|
||||||
|
report_type="full",
|
||||||
|
service=service,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created is not None
|
||||||
|
assert created["created"] is True
|
||||||
|
assert created["item"]["market"] == "tw"
|
||||||
|
|
||||||
|
listed = service.list_signals(source_report_id=2330)
|
||||||
|
assert listed["total"] == 1
|
||||||
|
assert listed["items"][0]["market"] == "tw"
|
||||||
|
|
||||||
|
|
||||||
def test_extract_and_persist_missing_price_plan_does_not_fabricate_fields(isolated_db) -> None:
|
def test_extract_and_persist_missing_price_plan_does_not_fabricate_fields(isolated_db) -> None:
|
||||||
service = DecisionSignalService(db_manager=isolated_db)
|
service = DecisionSignalService(db_manager=isolated_db)
|
||||||
result = _result()
|
result = _result()
|
||||||
|
|||||||
@@ -132,15 +132,21 @@ def test_trading_calendar_registers_tw_exchange_and_timezone() -> None:
|
|||||||
assert MARKET_TIMEZONE["tw"] == "Asia/Taipei"
|
assert MARKET_TIMEZONE["tw"] == "Asia/Taipei"
|
||||||
|
|
||||||
|
|
||||||
def test_tw_decision_signals_gracefully_skipped_in_data_layer_scope() -> None:
|
def test_tw_is_first_class_on_write_paths() -> None:
|
||||||
"""Data-layer MVP scope (per maintainer): tw is a recognized market, but the
|
"""TW is a first-class market on the decision-signal / portfolio / intelligence
|
||||||
decision-signal / portfolio / API service layer is a deliberate follow-up.
|
write paths, matching jp/kr.
|
||||||
|
|
||||||
The data layer recognizes tw, while the signal service does not yet accept it,
|
Regression guard: the analysis pipeline auto-extracts a DecisionSignal after
|
||||||
so the decision-signal extractor must SKIP tw gracefully (no signal, no noisy
|
history save (pipeline._extract_decision_signal_after_history_save). If `tw`
|
||||||
ValueError traceback) — guarded in build_decision_signal_payload_from_report.
|
were absent from VALID_MARKETS, _normalize_market("tw") would raise ValueError
|
||||||
|
on that main path and the signal would be silently dropped — making tw the
|
||||||
|
only yfinance-supported market that can be analyzed but never produces a signal.
|
||||||
"""
|
"""
|
||||||
|
from src.services.decision_signal_service import DecisionSignalService
|
||||||
from src.services.portfolio_service import VALID_MARKETS
|
from src.services.portfolio_service import VALID_MARKETS
|
||||||
|
from src.services.intelligence_service import _ALLOWED_MARKETS
|
||||||
|
|
||||||
assert get_market_for_stock("2330.TW") == "tw" # data layer recognizes tw
|
assert get_market_for_stock("2330.TW") == "tw" # data layer recognizes tw
|
||||||
assert "tw" not in VALID_MARKETS # signal/service layer intentionally deferred
|
assert DecisionSignalService._normalize_market("tw") == "tw"
|
||||||
|
assert "tw" in VALID_MARKETS
|
||||||
|
assert "tw" in _ALLOWED_MARKETS
|
||||||
|
|||||||
Reference in New Issue
Block a user