| from __future__ import annotations |
|
|
| import re |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| from app.config import RAW_DIR |
|
|
|
|
| @dataclass(frozen=True) |
| class IntermarketIndex: |
| symbol: str |
| name: str |
| price: str |
| change: str |
| change_percent: str |
| updated_at: str |
|
|
| @property |
| def trend(self) -> str: |
| if self.change.strip().startswith("-") or self.change_percent.strip().startswith("(-"): |
| return "decreasing" |
| if self.change.strip().startswith("+") or self.change_percent.strip().startswith("(+"): |
| return "increasing" |
| return "flat/unclear" |
|
|
|
|
| TARGET_INDEX_SYMBOLS = ["US 30", "Hang Seng", "Shanghai", "Nikkei 225", "KOSPI"] |
| INTERMARKET_QUERY_TERMS = [ |
| "lien thi truong", |
| "the gioi", |
| "thi truong chung", |
| "vn-index", |
| "vnindex", |
| "dow", |
| "hang seng", |
| "shanghai", |
| "shang hai", |
| "nikkei", |
| "kospi", |
| ] |
|
|
|
|
| def normalize_query(text: str) -> str: |
| import unicodedata |
|
|
| decomposed = unicodedata.normalize("NFD", str(text).lower()) |
| without_accents = "".join(char for char in decomposed if unicodedata.category(char) != "Mn") |
| return re.sub(r"\s+", " ", without_accents).strip() |
|
|
|
|
| def is_intermarket_query(question: str) -> bool: |
| normalized = normalize_query(question) |
| return any(term in normalized for term in INTERMARKET_QUERY_TERMS) |
|
|
|
|
| def latest_world_market_text() -> tuple[Path | None, str]: |
| market_dir = RAW_DIR / "text" / "market" |
| if not market_dir.exists(): |
| return None, "" |
|
|
| files = sorted( |
| market_dir.glob("*world_market*.txt"), |
| key=lambda path: path.stat().st_mtime, |
| reverse=True, |
| ) |
| if not files: |
| return None, "" |
|
|
| path = files[0] |
| return path, path.read_text(encoding="utf-8", errors="ignore") |
|
|
|
|
| def parse_world_market_indices(text: str) -> list[IntermarketIndex]: |
| lines = [line.strip() for line in str(text).splitlines() if line.strip()] |
| indexes: list[IntermarketIndex] = [] |
| for symbol in TARGET_INDEX_SYMBOLS: |
| try: |
| start = lines.index(symbol) |
| except ValueError: |
| continue |
|
|
| row = lines[start : start + 10] |
| if len(row) < 6: |
| continue |
| updated_at = next((value for value in row[6:] if re.match(r"^\d{1,2}:\d{2}:\d{2}$", value)), "") |
| indexes.append( |
| IntermarketIndex( |
| symbol=row[0], |
| name=row[2], |
| price=row[3], |
| change=row[4], |
| change_percent=row[5], |
| updated_at=updated_at, |
| ) |
| ) |
| return indexes |
|
|
|
|
| def build_intermarket_context(question: str) -> tuple[str, dict | None]: |
| if not is_intermarket_query(question): |
| return "", None |
|
|
| path, text = latest_world_market_text() |
| indexes = parse_world_market_indices(text) |
| if not path or not indexes: |
| return "", None |
|
|
| lines = [ |
| "Intermarket context from 24HMoney world market page.", |
| ( |
| "Use this as macro/market-background context only, not as a stock ticker. " |
| "For Vietnam market inference, treat broad weakness in US/Asia indexes as risk-off pressure, " |
| "and broad strength as supportive sentiment." |
| ), |
| ] |
| for item in indexes: |
| lines.append( |
| f"- {item.symbol} ({item.name}): {item.price}, {item.change} {item.change_percent}, " |
| f"trend={item.trend}, updated={item.updated_at}" |
| ) |
|
|
| source = { |
| "score": 1.0, |
| "ticker": "", |
| "scope": "market", |
| "modality": "text", |
| "structure_type": "intermarket_context", |
| "heading_path": [], |
| "source_path": path.relative_to(RAW_DIR.parent).as_posix(), |
| "url": "https://24hmoney.vn/world", |
| "artifact_path": path.relative_to(RAW_DIR.parent).as_posix(), |
| } |
| return "\n".join(lines), source |
|
|