Compare commits

..

2 Commits

Author SHA1 Message Date
xucheng
282cec080b 完善了一些数据获取的bug 2026-01-12 19:20:18 +08:00
xucheng
e786e885e6 实现了多货币的获取和显示 2026-01-12 09:33:52 +08:00
25 changed files with 2521 additions and 328 deletions

View File

@ -80,7 +80,8 @@ async def fetch_data(
market=request.market,
symbol=request.symbol,
data_source=request.data_source,
update_id=data_update.id
update_id=data_update.id,
currency=request.currency
)
return FetchDataResponse(
@ -96,7 +97,8 @@ def fetch_data_background(
market: str,
symbol: str,
data_source: str,
update_id: int
update_id: int,
currency: str = None
):
"""后台数据获取任务 - 完全同步执行,避免event loop冲突"""
import sys
@ -117,7 +119,8 @@ def fetch_data_background(
market=market,
symbol=symbol,
data_source=data_source,
update_id=update_id
update_id=update_id,
currency=currency
)
# 更新数据更新记录 - 使用psycopg2同步连接

View File

@ -9,7 +9,7 @@ import websocket
import psycopg2
import pandas as pd
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
from datetime import datetime
from datetime import datetime, timedelta
from dotenv import load_dotenv
from pathlib import Path
import logging
@ -81,7 +81,7 @@ PRICE_CONFIG = {
}
STOCKCARD_CONFIG = {
"period": 15,
"period": 10,
"unit": 100000000
}
@ -300,6 +300,14 @@ class BloombergClient:
except Exception:
conn.rollback() # Should not happen with IF NOT EXISTS but good practice
# Migrate update_date to TIMESTAMP
try:
cur.execute("ALTER TABLE stockcard ALTER COLUMN update_date TYPE TIMESTAMP USING update_date::timestamp;")
except Exception as e:
# Likely already converted or failed, log but don't crash
logger.info(f"Schema migration note (update_date): {e}")
conn.rollback()
conn.commit()
def save_data(self, data_list):
@ -307,6 +315,8 @@ class BloombergClient:
if not data_list:
return
conn = self._get_db_connection()
if not conn: return
@ -354,7 +364,7 @@ class BloombergClient:
id,
ROW_NUMBER() OVER(
PARTITION BY company_code, currency, indicator, value_date
ORDER BY update_date DESC
ORDER BY update_date DESC, id DESC
) as rn
FROM
stockcard
@ -399,7 +409,7 @@ class BloombergClient:
# --- Core Fetching Logic ---
def fetch_company(self, market, symbol, progress_callback=None):
def fetch_company(self, market, symbol, progress_callback=None, force_currency=None):
"""
Main entry point to fetch data for a single company.
"""
@ -407,12 +417,28 @@ class BloombergClient:
# If symbol already has Equity, use it. Else append.
if "Equity" in symbol:
company_code = symbol
bucket_code = symbol # Usage for bucket?
# If symbol comes in as '631 HK Equity', we might want to standardize?
# Assuming symbol is correct input.
else:
# Special case for China market: use 'CH Equity' instead of 'CN Equity'
mapped_market = "CH" if market == "CN" else market
# Canonical Code (Store in DB): Keep explicit zeros if provided in symbol
company_code = f"{symbol} {mapped_market} Equity"
today_str = datetime.now().strftime('%Y-%m-%d')
# Query Ticker (Send to Bloomberg): Handle HK special case
query_ticker = company_code
if market == "HK" or (len(company_code.split()) > 1 and company_code.split()[1] == "HK"):
# Extract number part
parts = company_code.split()
ticker_part = parts[0]
if ticker_part.isdigit():
short_ticker = str(int(ticker_part))
# Reconstruct: 631 HK Equity
query_ticker = f"{short_ticker} {' '.join(parts[1:])}"
today_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
logger.info(f"🚀 Starting fetch for: {company_code}")
if progress_callback: progress_callback("Starting Bloomberg session...", 0)
@ -438,61 +464,85 @@ if 'bquery' not in globals():
try:
self.execute_remote_code(init_code)
# 1. Fetch Basic Info
logger.info("Fetching Basic Data...")
if progress_callback: progress_callback("Fetching Company Basic Info...", 10)
basic_data = self._fetch_basic_remote(company_code, "USD")
self.save_data(basic_data)
# 1. Fetch Basic Info
# Determine currency
if force_currency and force_currency != "Auto":
currency = force_currency
logger.info(f"Using forced currency: {currency}")
else:
currency = "USD"
if "JP" in market.upper(): currency = "JPY"
elif "VN" in market.upper(): currency = "VND"
elif "CN" in market.upper(): currency = "CNY"
elif "HK" in market.upper(): currency = "HKD"
logger.info(f"Using auto-detected currency: {currency}")
logger.info(f"Using currency: {currency}")
# 2. Fetch Basic Info
logger.info("Fetching Basic Data...")
if progress_callback: progress_callback("Fetching Company Basic Info...", 10)
try:
basic_data = self._fetch_basic_remote(company_code, currency, query_ticker=query_ticker)
logger.info(f"DEBUG: basic_data before save: {basic_data}")
self.save_data(basic_data)
except Exception as e:
logger.error(f"Error fetching basic data: {e}")
# 2. Fetch Currency Data
# 3. Fetch Currency Data
logger.info("Fetching Currency Data...")
if progress_callback: progress_callback(f"正在获取货币指标 ({currency})...", 30)
curr_data = self._fetch_series_remote(company_code, currency, CURRENCY_CONFIG, "currency")
curr_data = []
try:
curr_data = self._fetch_series_remote(company_code, currency, CURRENCY_CONFIG, "currency", query_ticker=query_ticker)
self.save_data(curr_data)
except Exception as e:
logger.error(f"Error fetching currency series: {e}")
# 3. Fetch Non-Currency Data
# 4. Fetch Non-Currency Data
logger.info("Fetching Non-Currency Data...")
if progress_callback: progress_callback("正在获取非货币指标...", 50)
non_curr_data = self._fetch_series_remote(company_code, currency, NON_CURRENCY_CONFIG, "non_currency")
try:
non_curr_data = self._fetch_series_remote(company_code, currency, NON_CURRENCY_CONFIG, "non_currency", query_ticker=query_ticker)
self.save_data(non_curr_data)
except Exception as e:
logger.error(f"Error fetching non-currency series: {e}")
# 4. Fetch Price Data (Aligned with Revenue Dates)
# 5. Fetch Price Data (Aligned with Revenue Dates)
logger.info("Fetching Price Data (Aligned)...")
if progress_callback: progress_callback("正在获取价格指标...", 70)
# Extract Revenue dates
revenue_dates = []
rev_key = CURRENCY_CONFIG.get("Revenue", "SALES_REV_TURN")
# The saved data uses indicator name from config keys (e.g. "Revenue")
# So looking for "Revenue" in saved entries
if curr_data:
for item in curr_data:
# Check for "Revenue" (case insensitive match if needed)
if item['indicator'].lower() == 'revenue':
# Check for "Revenue"
if item['indicator'].lower() == "revenue": # Robust case-insensitive check
if item['value_date']:
# Ensure YYYY-MM-DD
revenue_dates.append(item['value_date'])
# Remove specs, duplicates, sort
# Remove duplicates, sort
revenue_dates = sorted(list(set(revenue_dates)), reverse=True)
if revenue_dates:
logger.info(f"Found {len(revenue_dates)} revenue reporting dates. Fetching aligned price data...")
price_data = self._fetch_price_by_dates_remote(company_code, currency, revenue_dates)
try:
price_data = self._fetch_price_by_dates_remote(company_code, currency, revenue_dates, query_ticker=query_ticker)
self.save_data(price_data)
except Exception as e:
logger.error(f"Error fetching aligned price data: {e}")
else:
logger.warning("No revenue dates found. Falling back to yearly price fetch.")
price_data = self._fetch_series_remote(company_code, currency, PRICE_CONFIG, "price")
logger.warning("No revenue dates found. Falling back to yearly price fetch (Dec 31).")
# Generate last 10 years Dec 31
fallback_dates = []
current_year = datetime.now().year
for i in range(1, 11):
fallback_dates.append(f"{current_year - i}-12-31")
try:
price_data = self._fetch_price_by_dates_remote(company_code, currency, fallback_dates, query_ticker=query_ticker)
self.save_data(price_data)
except Exception as e:
logger.error(f"Error fetching fallback price data: {e}")
# 5. Cleanup
if progress_callback: progress_callback("Finalizing data...", 90)
@ -505,74 +555,80 @@ if 'bquery' not in globals():
if progress_callback: progress_callback(f"Error: {e}", 0)
raise e
def _fetch_basic_remote(self, company_code, currency):
def _fetch_basic_remote(self, company_code, currency, query_ticker=None):
"""Generates code to fetch basic data"""
target_ticker = query_ticker if query_ticker else company_code
code = f"""
def get_basic():
company = "{company_code}"
query_ticker = "{target_ticker}"
curr = "{currency}"
res_list = []
# 1. BQL query
q = f"for(['{{company}}']) get(name,listing_date,pe_ratio,px_to_book_ratio,cur_mkt_cap(currency={{curr}}),PCT_REVENUE_FROM_FOREIGN_SOURCES)"
try:
df = bquery.bql(q)
if not df.empty:
def get_val(df, field_name):
if field_name == 'name':
rows = df[df['field'] == 'name']
elif 'cur_mkt_cap' in field_name:
rows = df[df['field'].str.contains('cur_mkt_cap')]
elif 'FOREIGN' in field_name:
rows = df[df['field'].str.contains('FOREIGN')]
else:
rows = df[df['field'] == field_name]
# 1. BDP Query for most fields (except Market Cap)
fields = ["NAME", "PE_RATIO", "PX_TO_BOOK_RATIO", "PCT_REVENUE_FROM_FOREIGN_SOURCES", "DIVIDEND_12_MONTH_YIELD", "EQY_INIT_PO_DT"]
if not rows.empty:
val = rows['value'].iloc[0]
# Handle Timestamp object from remote pandas
if hasattr(val, 'strftime'):
return val.strftime('%Y-%m-%d')
return str(val) if val is not None else None
try:
# Use overrides for currency. Expects list of tuples.
# Try both CURRENCY (for price) and EQY_FUND_CRNCY (for fundamentals)
df = bquery.bdp([query_ticker], fields, overrides=[('CURRENCY', curr), ('EQY_FUND_CRNCY', curr)])
if not df.empty:
def safe_get(df, col):
if col in df.columns:
val = df[col].iloc[0]
if val is None: return None
if hasattr(val, 'strftime'): return val.strftime('%Y-%m-%d')
return str(val)
return None
res_list.append({{"indicator": "company_name", "value": get_val(df, 'name')}})
res_list.append({{"indicator": "pe_ratio", "value": get_val(df, 'pe_ratio')}})
res_list.append({{"indicator": "pb_ratio", "value": get_val(df, 'pb_ratio')}})
res_list.append({{"indicator": "market_cap", "value": get_val(df, 'cur_mkt_cap')}})
res_list.append({{"indicator": "Rev_Abroad", "value": get_val(df, 'FOREIGN')}})
except Exception as e:
print(f"Basic BQL Error: {{e}}")
# 2. BDP for IPO and Dividend
try:
did = bquery.bdp([company], ["DIVIDEND_12_MONTH_YIELD"])
if not did.empty and 'DIVIDEND_12_MONTH_YIELD' in did.columns:
res_list.append({{"indicator": "dividend_yield", "value": str(did['DIVIDEND_12_MONTH_YIELD'][0])}})
ipo = bquery.bdp([company], ["EQY_INIT_PO_DT"])
if not ipo.empty and 'EQY_INIT_PO_DT' in ipo.columns:
val = ipo['EQY_INIT_PO_DT'][0]
val_str = str(val)
if hasattr(val, 'strftime'):
val_str = val.strftime('%Y-%m-%d')
res_list.append({{"indicator": "IPO_date", "value": val_str}})
res_list.append({{"indicator": "company_name", "value": safe_get(df, 'NAME')}})
res_list.append({{"indicator": "pe_ratio", "value": safe_get(df, 'PE_RATIO')}})
res_list.append({{"indicator": "pb_ratio", "value": safe_get(df, 'PX_TO_BOOK_RATIO')}})
# res_list.append({{"indicator": "market_cap", "value": safe_get(df, 'CUR_MKT_CAP')}}) # Moved to BDH
res_list.append({{"indicator": "Rev_Abroad", "value": safe_get(df, 'PCT_REVENUE_FROM_FOREIGN_SOURCES')}})
res_list.append({{"indicator": "dividend_yield", "value": safe_get(df, 'DIVIDEND_12_MONTH_YIELD')}})
res_list.append({{"indicator": "IPO_date", "value": safe_get(df, 'EQY_INIT_PO_DT')}})
except Exception as e:
print(f"Basic BDP Error: {{e}}")
# 2. BDH Query for Market Cap (To enforce Currency)
try:
import datetime
end_date = datetime.datetime.now().strftime('%Y%m%d')
start_date = (datetime.datetime.now() - datetime.timedelta(days=10)).strftime('%Y%m%d')
# Fetch CUR_MKT_CAP via BDH to respect currency option
df_cap = bquery.bdh([query_ticker], ['CUR_MKT_CAP'], start_date=start_date, end_date=end_date, options={{'currency': curr}})
if not df_cap.empty:
# BDH usually returns Market Cap in Millions.
# User simplified requirement: Keep it in Millions.
# Frontend will divide by 100 to get "Yi".
val_millions = df_cap.iloc[-1]['CUR_MKT_CAP']
res_list.append({{"indicator": "market_cap", "value": str(val_millions)}})
except Exception as e:
print(f"Basic BDH Market Cap Error: {{e}}")
# Format result
final_res = []
today = datetime.now().strftime('%Y-%m-%d')
import datetime
today_dt = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
for item in res_list:
if item['value']:
# Check against None string too if needed
if item['value'] and str(item['value']) != 'None':
final_res.append({{
"Company_code": company,
"update_date": today,
"update_date": today_dt,
"currency": curr,
"indicator": item['indicator'],
"value": item['value'],
"value_date": today
"value_date": today_dt
}})
print("JSON_START")
@ -583,27 +639,35 @@ get_basic()
"""
return self._execute_and_parse(code)
def _fetch_series_remote(self, company_code, currency, config_dict, result_type):
def _fetch_series_remote(self, company_code, currency, config_dict, result_type, query_ticker=None):
"""Generates code to fetch series data using BDH"""
target_ticker = query_ticker if query_ticker else company_code
config_json = json.dumps(config_dict)
period_years = STOCKCARD_CONFIG['period']
start_year = datetime.now().year - period_years
start_date = f"{start_year}0101"
end_date = datetime.now().strftime('%Y%m%d')
# Calculate start date
end_date = datetime.now()
start_date = end_date - timedelta(days=period_years*365)
# Format dates for BDH (YYYYMMDD)
start_date_str = start_date.strftime('%Y%m%d')
end_date_str = end_date.strftime('%Y%m%d')
# BDH Options
bdh_options = {
'periodicitySelection': 'YEARLY',
'currency': currency,
'nonTradingDayFillOption': 'ALL_CALENDAR_DAYS',
'nonTradingDayFillMethod': 'PREVIOUS_VALUE'
"periodicityAdjustment": "FISCAL",
"periodicitySelection": "YEARLY",
"currency": currency,
# "nonTradingDayFillOption": "NON_TRADING_WEEKDAYS",
# "nonTradingDayFillMethod": "PREVIOUS_VALUE"
}
bdh_opts_json = json.dumps(bdh_options)
code = f"""
def get_series():
company = "{company_code}"
query_ticker = "{target_ticker}"
curr = "{currency}"
config = {config_json}
@ -614,10 +678,10 @@ def get_series():
try:
df = bquery.bdh(
[company],
[query_ticker],
fields,
start_date='{start_date}',
end_date='{end_date}',
start_date='{start_date_str}',
end_date='{end_date_str}',
options={bdh_opts_json}
)
@ -656,7 +720,7 @@ def get_series():
res_list.append({{
"Company_code": company,
"update_date": "{datetime.now().strftime('%Y-%m-%d')}",
"update_date": "{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"currency": curr,
"indicator": indicator_name,
"value": val_str,
@ -674,11 +738,12 @@ get_series()
"""
return self._execute_and_parse(code)
def _fetch_price_by_dates_remote(self, company_code, currency, dates):
def _fetch_price_by_dates_remote(self, company_code, currency, dates, query_ticker=None):
"""Generates code to fetch price/mkt_cap for specific dates"""
if not dates:
return []
target_ticker = query_ticker if query_ticker else company_code
dates_json = json.dumps(dates)
config_json = json.dumps(PRICE_CONFIG)
@ -692,6 +757,7 @@ get_series()
code = f"""
def get_price_by_dates():
company = "{company_code}"
query_ticker = "{target_ticker}"
curr = "{currency}"
dates = {dates_json}
config = {config_json}
@ -705,10 +771,12 @@ def get_price_by_dates():
# d_str is 'YYYY-MM-DD', bdh needs 'YYYYMMDD'
d_param = d_str.replace('-', '')
for mnemonic, indicator_name in mnemonic_map.items():
field = mnemonic
try:
df = bquery.bdh(
[company],
fields,
[query_ticker],
[field],
start_date=d_param,
end_date=d_param,
options={bdh_opts_json}
@ -716,16 +784,17 @@ def get_price_by_dates():
if not df.empty:
for _, row in df.iterrows():
# value_date is d_str
for mnemonic, indicator_name in mnemonic_map.items():
col_name = mnemonic
# bdh columns might be tuple or just string depending on request
# usually 'PX_LAST'
val = None
if col_name in df.columns:
val = row[col_name]
if field in df.columns:
val = row[field]
elif field in row:
val = row[field]
else:
# Try case insensitive
for c in df.columns:
if c.upper() == field:
val = row[c]
break
if pd.isna(val) or val is None:
continue
@ -734,14 +803,16 @@ def get_price_by_dates():
res_list.append({{
"Company_code": company,
"update_date": "{datetime.now().strftime('%Y-%m-%d')}",
"update_date": "{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"currency": curr,
"indicator": indicator_name,
"value": val_str,
"value_date": d_str
}})
except Exception as e:
print(f"Error fetching price for {{d_str}}: {{e}}")
# print(f"BDH Error for {{field}}: {{e}}")
pass
print("JSON_START")
print(json.dumps(res_list))
@ -755,7 +826,9 @@ get_price_by_dates()
"""Execute code and parse [JSON_START]...[JSON_END]"""
raw_output_list = self.execute_remote_code(code)
raw_output = "".join(raw_output_list) if isinstance(raw_output_list, list) else str(raw_output_list)
# logger.debug(f"Remote execution returned {len(raw_output)} chars.") # Optional debug
# Always log raw output first few chars for debug
logger.info(f"REMOTE RAW OUTPUT: {raw_output[:1000]}")
# Actually let's log everything if it fails to find JSON
# Simple parser
try:
@ -768,7 +841,7 @@ get_price_by_dates()
logger.info(f"✅ Parsed {len(data) if isinstance(data, list) else 1} items from remote.")
return data
else:
logger.warning(f"⚠️ No JSON output found in remote response.")
logger.warning(f"⚠️ No JSON output found in remote response. Raw: {raw_output}")
return []
except Exception as e:
logger.error(f"❌ Error parsing JSON from remote: {e}")

View File

@ -52,7 +52,7 @@ class BloombergFetcher(DataFetcher):
# placeholders for 'IN' clause
placeholders = ','.join(['%s'] * len(indicators))
query = f"""
SELECT indicator, value, value_date
SELECT indicator, value, value_date, currency
FROM stockcard
WHERE Company_code = %s AND indicator IN ({placeholders})
ORDER BY value_date DESC
@ -65,15 +65,17 @@ class BloombergFetcher(DataFetcher):
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows, columns=['indicator', 'value', 'value_date'])
df = pd.DataFrame(rows, columns=['indicator', 'value', 'value_date', 'currency'])
# Pivot
# Index: value_date
# Index: [value_date, currency] -> ensures we keep both JPY and USD rows for the same date
# Columns: indicator
# Values: value
df_pivot = df.pivot(index='value_date', columns='indicator', values='value').reset_index()
df_pivot = df.pivot(index=['value_date', 'currency'], columns='indicator', values='value').reset_index()
df_pivot.rename(columns={'value_date': 'end_date'}, inplace=True)
# No need to manual merge currency back, it's already in the index
# Clean columns? No, they are standard from config.
return df_pivot
@ -103,14 +105,12 @@ class BloombergFetcher(DataFetcher):
except Exception as e:
print(f"Bloomberg fetch failed (ignoring, checking DB): {e}")
def sync_all_data(self, symbol: str, progress_callback=None) -> None:
def sync_all_data(self, symbol: str, progress_callback=None, force_currency=None):
"""
全量同步 Bloomberg 数据
触发一次全量抓取即可不需要分别抓取各个报表
数据会自动存储到数据库
Sync all data for the company.
Delegates to the universal client.
"""
self._ensure_data_fetched(symbol, progress_callback=progress_callback)
self.client.fetch_company(self.market, symbol, progress_callback=progress_callback, force_currency=force_currency)
def get_income_statement(self, symbol: str) -> pd.DataFrame:
"""兼容性空方法"""

View File

@ -47,6 +47,7 @@ class FetchDataRequest(BaseModel):
company_name: str
data_source: str
force_refresh: bool = False
currency: Optional[str] = None
class FetchDataResponse(BaseModel):
update_id: int

View File

@ -58,8 +58,9 @@ async def get_bloomberg_data(
# 2. 获取该公司的所有数据
# schema: indicator, value, value_date (作为报告期)
# Added update_date
query = text("""
SELECT indicator, value, value_date, currency
SELECT indicator, value, value_date, currency, update_date
FROM stockcard
WHERE Company_code = :code
""")
@ -73,20 +74,30 @@ async def get_bloomberg_data(
val = row.value
v_date = row.value_date
curr = row.currency
u_date = row.update_date
if not v_date:
continue
date_str = v_date.isoformat() if hasattr(v_date, 'isoformat') else str(v_date)
# Handle update_date formatting
update_date_str = ""
if u_date:
update_date_str = u_date.isoformat() if hasattr(u_date, 'isoformat') else str(u_date)
if date_str not in data_by_date:
data_by_date[date_str] = {
# Key by (date, currency) to support multiple currencies for the same date
unique_key = f"{date_str}_{curr}"
if unique_key not in data_by_date:
data_by_date[unique_key] = {
"end_date": date_str,
"currency": curr
"currency": curr,
"update_date": ""
}
elif data_by_date[date_str].get("currency") is None and curr:
# If existing entry has no currency, but we have one now, update it
data_by_date[date_str]["currency"] = curr
# Update the group's update_date if we find a newer one in this batch
if update_date_str > data_by_date[unique_key]["update_date"]:
data_by_date[unique_key]["update_date"] = update_date_str
# Normalize key to match frontend expectation (mostly lowercase)
# e.g. "ROE" -> "roe", "PE" -> "pe"
@ -95,7 +106,7 @@ async def get_bloomberg_data(
# Special case mapping if needed, but lowercase covers most
# frontend uses: net_income, revenue, etc.
data_by_date[date_str][norm_indicator] = val
data_by_date[unique_key][norm_indicator] = val
# 4. 转换为列表并排序
full_list = list(data_by_date.values())

View File

@ -209,7 +209,8 @@ def fetch_financial_data_sync(
market: str,
symbol: str,
data_source: str,
update_id: int
update_id: int,
currency: Optional[str] = None
):
"""
同步方式获取财务数据在后台任务中调用
@ -258,6 +259,9 @@ def fetch_financial_data_sync(
import inspect
sig = inspect.signature(fetcher.sync_all_data)
if 'progress_callback' in sig.parameters:
if 'force_currency' in sig.parameters:
fetcher.sync_all_data(formatted_symbol, progress_callback=progress_callback, force_currency=currency)
else:
fetcher.sync_all_data(formatted_symbol, progress_callback=progress_callback)
else:
fetcher.sync_all_data(formatted_symbol)

70
backend/debug_bdh.py Normal file
View File

@ -0,0 +1,70 @@
import sys
import os
import time
import logging
# Ensure we can import from backend
sys.path.append(os.path.join(os.getcwd(), 'backend'))
# Setup logging
logging.basicConfig(level=logging.INFO)
from app.clients.bloomberg_client import BloombergClient
def test_bdh_currency():
print("Initializing BloombergClient...")
client = BloombergClient()
ticker = "6301 JP Equity"
code = """
import sys
import datetime
try:
if 'bquery' not in globals():
import bquery
except ImportError:
pass
ticker = "{ticker}"
fields = ["CUR_MKT_CAP", "PX_LAST"]
print(f"--- BDH Currency Test for {{ticker}} ---")
def get_bdh_val(curr, desc):
try:
# BDH request for last 5 days to ensure we hit a trading day
end_date = datetime.datetime.now().strftime('%Y%m%d')
start_date = (datetime.datetime.now() - datetime.timedelta(days=10)).strftime('%Y%m%d')
# Pass currency in options
df = bquery.bdh([ticker], fields, start_date=start_date, end_date=end_date, options={{'currency': curr}})
if not df.empty:
print(f"--- {{desc}} (Currency: {{curr}}) ---")
# Get last row
last_row = df.iloc[-1]
for col in df.columns:
print(f" {{col}}: {{last_row[col]}}")
else:
print(f"{{desc}} Empty")
except Exception as e:
print(f"{{desc}} Error: {{e}}")
# 1. JPY
get_bdh_val('JPY', "BDH_JPY")
# 2. USD
get_bdh_val('USD', "BDH_USD")
"""
formatted_code = code.format(ticker=ticker)
print(f"Executing remote logic for {ticker}...")
output = client.execute_remote_code(formatted_code)
print("Remote Output:")
print(output)
if __name__ == "__main__":
test_bdh_currency()

View File

@ -0,0 +1,67 @@
import sys
import os
import time
import logging
# Ensure we can import from backend
sys.path.append(os.path.join(os.getcwd(), 'backend'))
# Setup logging
logging.basicConfig(level=logging.INFO)
from app.clients.bloomberg_client import BloombergClient
def test_bdp_options_injection():
print("Initializing BloombergClient...")
client = BloombergClient()
ticker = "6301 JP Equity"
code = """
import sys
try:
if 'bquery' not in globals():
import bquery
except ImportError:
pass
ticker = "{ticker}"
fields = ["CUR_MKT_CAP", "PX_LAST"]
print(f"--- Option Injection Test for {{ticker}} ---")
def get_val_via_options(custom_overrides, desc):
try:
# Pass overrides=None, pass custom overrides via options
# Escape braces for format string by doubling them
df = bquery.bdp([ticker], fields, overrides=None, options={{'overrides': custom_overrides}})
if not df.empty:
print(f"--- {{desc}} ---")
for col in df.columns:
print(f" {{col}}: {{df.iloc[0][col]}}")
print(f" Sent: {{custom_overrides}}")
else:
print(f"{{desc}} Empty")
except Exception as e:
print(f"{{desc}} Error: {{e}}")
# 1. Direct fieldId (No wrapper)
get_val_via_options([{{'fieldId': 'CURRENCY', 'value': 'USD'}}], "Try1_Direct_Dict")
# 2. Wrapped in 'overrides' (Same as current impl)
get_val_via_options([{{'overrides': {{'fieldId': 'CURRENCY', 'value': 'USD'}}}}], "Try2_Wrapped_Overrides")
# 3. Wrapped in 'override' (Singular)
get_val_via_options([{{'override': {{'fieldId': 'CURRENCY', 'value': 'USD'}}}}], "Try3_Wrapped_Override")
"""
formatted_code = code.format(ticker=ticker)
print(f"Executing remote logic for {ticker}...")
output = client.execute_remote_code(formatted_code)
print("Remote Output:")
print(output)
if __name__ == "__main__":
test_bdp_options_injection()

View File

@ -0,0 +1,94 @@
import asyncio
from app.clients.bloomberg_client import BloombergClient, CURRENCY_CONFIG
async def run():
client = BloombergClient()
company = "2503 JP Equity"
# Simulate fetch of currency data (which includes Revenue)
# CURRENCY_CONFIG key "Revenue" is "SALES_REV_TURN"
print(f"Debugging Revenue Dates for {company} (CNY)...")
# We'll use the exact simulating logic of _fetch_series_remote but print the dates
config = {"Revenue": CURRENCY_CONFIG["Revenue"]}
# Force CNY
currency = "CNY"
# Generate the code that _fetch_series_remote generates
# We need to replicate the remote code construction roughly or call execute_remote_code
import json
from datetime import datetime
STOCKCARD_CONFIG = { "period": 10 }
period_years = STOCKCARD_CONFIG['period']
start_year = datetime.now().year - period_years
start_date = f"{start_year}0101"
end_date = datetime.now().strftime('%Y%m%d')
bdh_options = {
'periodicitySelection': 'YEARLY',
'currency': currency,
'nonTradingDayFillOption': 'ALL_CALENDAR_DAYS',
'nonTradingDayFillMethod': 'PREVIOUS_VALUE'
}
config_json = json.dumps(config)
bdh_opts_json = json.dumps(bdh_options)
remote_code = f"""
def debug_series():
company = "{company}"
curr = "{currency}"
config = {config_json}
mnemonic_map = {{v.upper(): k for k, v in config.items()}}
fields = list(mnemonic_map.keys())
try:
df = bquery.bdh(
[company],
fields,
start_date='{start_date}',
end_date='{end_date}',
options={bdh_opts_json}
)
print(f"--- Raw Revenue Data (CNY) ---")
if not df.empty:
print(df)
revenue_dates = []
for _, row in df.iterrows():
# Extract date
date_val = None
if 'date' in df.columns: date_val = row['date']
elif 'DATE' in df.columns: date_val = row['DATE']
if date_val:
d_str = str(date_val)[:10]
# Check for value
val = None
if fields[0] in row: val = row[fields[0]]
elif fields[0] in df.columns: val = row[fields[0]]
if val is not None:
revenue_dates.append(d_str)
print(f"\\nExtracted Revenue Dates: {{sorted(revenue_dates, reverse=True)}}")
else:
print("DataFrame is empty.")
except Exception as e:
print(f"Error: {{e}}")
debug_series()
"""
result = client.execute_remote_code(remote_code)
print(result)
if __name__ == "__main__":
asyncio.run(run())

42
backend/inspect_db.py Normal file
View File

@ -0,0 +1,42 @@
import os
import psycopg2
from dotenv import load_dotenv
load_dotenv()
def inspect_schema():
db_host = os.getenv("DB_HOST", "192.168.3.195")
db_user = os.getenv("DB_USER", "value")
db_pass = os.getenv("DB_PASSWORD", "Value609!")
db_name = os.getenv("DB_NAME", "fa3")
db_port = os.getenv("DB_PORT", "5432")
try:
conn = psycopg2.connect(
host=db_host, user=db_user, password=db_pass, dbname=db_name, port=db_port
)
cur = conn.cursor()
print("--- Table Columns ---")
cur.execute("""
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'stockcard';
""")
rows = cur.fetchall()
for row in rows:
print(f"{row[0]}: {row[1]}")
print("\n--- Recent Records update_date ---")
cur.execute("SELECT update_date FROM stockcard ORDER BY id DESC LIMIT 5")
rows = cur.fetchall()
for row in rows:
print(f"{row[0]} (Type: {type(row[0])})")
conn.close()
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
inspect_schema()

View File

@ -0,0 +1,39 @@
import os
import psycopg2
import logging
from datetime import datetime
# Config logging
logging.basicConfig(level=logging.INFO)
def inspect_db():
db_host = os.getenv("DB_HOST", "192.168.3.195")
db_user = os.getenv("DB_USER", "value")
db_pass = os.getenv("DB_PASSWORD", "Value609!")
db_name = os.getenv("DB_NAME", "fa3")
conn = psycopg2.connect(
host=db_host, user=db_user, password=db_pass, dbname=db_name
)
with conn.cursor() as cur:
# Check basic data market cap
cur.execute("""
SELECT update_date, indicator, value, value_date, currency, source
FROM stockcard
WHERE Company_code LIKE '6301 JP%'
AND (indicator = 'market_cap' OR indicator = 'Market_Cap')
ORDER BY update_date DESC, value_date DESC
LIMIT 20;
""")
rows = cur.fetchall()
print(f"{'Update Date':<20} | {'Indicator':<15} | {'Currency':<5} | {'Value Date':<12} | {'Value (Raw)':<20} | {'Source'}")
print("-" * 100)
for row in rows:
print(f"{str(row[0]):<20} | {row[1]:<15} | {row[4]:<5} | {str(row[3]):<12} | {row[2]:<20} | {row[5]}")
conn.close()
if __name__ == "__main__":
inspect_db()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
import sys
import os
import logging
import json
from datetime import datetime, timedelta
# Add app to path
sys.path.append(os.path.join(os.path.dirname(__file__), 'app'))
from app.clients.bloomberg_client import BloombergClient
# Config logging
logging.basicConfig(level=logging.INFO)
def test_market_cap():
print("Initializing Client...")
client = BloombergClient()
# Target: 6301 JP Equity (Komatsu Ltd)
company_code = "6301 JP Equity"
# Dates to fetch (last few years)
dates = ["2023-03-31", "2024-03-31"] # Komatsu fiscal year end is usually March 31
# 1. Fetch in JPY (Local)
print("\nfetching Market Cap in JPY...")
try:
data_jpy = client._fetch_price_by_dates_remote(
company_code=company_code,
currency="JPY",
dates=dates,
query_ticker=company_code
)
print("--- Result JPY ---")
print(json.dumps(data_jpy, indent=2, ensure_ascii=False))
except Exception as e:
print(f"Error JPY: {e}")
# 2. Fetch in CNY (Converted) - Using Series Remote (Fallback Path)
print("\nfetching Market Cap in CNY (Series Remote Fallback)...")
try:
from app.clients.bloomberg_client import PRICE_CONFIG
# We need to simulate the fallback call:
# price_data = self._fetch_series_remote(company_code, currency, PRICE_CONFIG, "price", query_ticker=query_ticker)
data_cny = client._fetch_series_remote(
company_code=company_code,
currency="CNY",
config_dict=PRICE_CONFIG,
result_type="price",
query_ticker=company_code
)
print("--- Result CNY (Series) ---")
print(json.dumps(data_cny[:5], indent=2, ensure_ascii=False))
except Exception as e:
print(f"Error CNY: {e}")
if __name__ == "__main__":
test_market_cap()

View File

@ -0,0 +1,66 @@
import sys
import os
import logging
import json
from datetime import datetime, timedelta
# Add app to path
sys.path.append(os.path.join(os.path.dirname(__file__), 'app'))
from app.clients.bloomberg_client import BloombergClient
# Config logging
logging.basicConfig(level=logging.INFO)
def test_series():
print("Initializing Client...")
client = BloombergClient()
# Simulate the logic in fetch_company for HK
symbol = "00631"
market = "HK"
# 1. Ticker Logic
mapped_market = "CH" if market == "CN" else market
company_code = f"{symbol} {mapped_market} Equity" # "00631 HK Equity"
query_ticker = company_code
if market == "HK" or (len(company_code.split()) > 1 and company_code.split()[1] == "HK"):
parts = company_code.split()
ticker_part = parts[0]
if ticker_part.isdigit():
short_ticker = str(int(ticker_part))
query_ticker = f"{short_ticker} {' '.join(parts[1:])}" # "631 HK Equity"
print(f"Company Code (Storage): {company_code}")
print(f"Query Ticker (Bloomberg): {query_ticker}")
currency = "HKD"
# 2. Test Currency Data Fetch (Revenue)
print("\nfetching Currency Data (Series)...")
curr_indicators = {
"Revenue": "SALES_REV_TURN"
}
try:
# Call the internal method directly
data = client._fetch_series_remote(
company_code=company_code,
currency=currency,
config_dict=curr_indicators,
result_type="currency",
query_ticker=query_ticker
)
print("--- Result (First 5 items) ---")
print(json.dumps(data[:5], indent=2, ensure_ascii=False))
print(f"Total items: {len(data)}")
except Exception as e:
print(f"Error: {e}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
test_series()

View File

@ -0,0 +1,57 @@
import sys
import asyncio
from app.clients.bloomberg_client import BloombergClient
async def run():
client = BloombergClient()
company = "2503 JP Equity"
# We want to check 2024-12-31 data for JPY, CNY, USD
currencies = ["JPY", "CNY", "USD"]
print(f"Testing Market Cap for {company} on 2024-12-31 in {currencies}")
remote_code = f"""
def test_currency():
company = "{company}"
currencies = {currencies}
date = "20241231"
results = {{}}
try:
for curr in currencies:
print(f"Fetching for {{curr}}...")
# Use BDH for historical specific date
df = bquery.bdh(
[company],
['CUR_MKT_CAP'],
start_date=date,
end_date=date,
options={{'currency': curr, 'periodicitySelection': 'DAILY', 'nonTradingDayFillOption': 'ALL_CALENDAR_DAYS', 'nonTradingDayFillMethod': 'PREVIOUS_VALUE'}}
)
if not df.empty and 'CUR_MKT_CAP' in df.columns:
val = df['CUR_MKT_CAP'].iloc[0]
results[curr] = val
else:
results[curr] = "No Data"
except Exception as e:
print(f"Error: {{e}}")
import json
print("JSON_START")
print(json.dumps(results))
print("JSON_END")
test_currency()
"""
result = client.execute_remote_code(remote_code)
print("\nCheck Result:")
print(result)
if __name__ == "__main__":
asyncio.run(run())

View File

@ -0,0 +1,41 @@
import sys
import asyncio
import json
from app.clients.bloomberg_client import BloombergClient
# Mock the PRICE_CONFIG to ensure we are fetching what we expect if it's not imported
# But BloombergClient imports it from configuration.
# Let's rely on the real client.
async def run():
client = BloombergClient()
company = "2503 JP Equity"
dates = ['2024-12-31', '2023-12-31', '2022-12-31', '2021-12-31', '2020-12-31', '2019-12-31', '2018-12-31', '2017-12-31', '2016-12-31']
currencies = ["CNY"]
print(f"Testing _fetch_price_by_dates_remote for {company} on {dates}...")
for curr in currencies:
print(f"\n--- Fetching in {curr} ---")
try:
# The method allows fetching price data for specific dates
# It uses PRICE_CONFIG which includes 'Market_Cap'
data = client._fetch_price_by_dates_remote(company, curr, dates)
# Filter for Market_Cap to show user
mkt_caps = [d for d in data if d['indicator'] == 'Market_Cap']
if mkt_caps:
for item in mkt_caps:
print(f"Currency: {item['currency']}, Date: {item['value_date']}, Value: {item['value']}")
else:
print("No Market_Cap data returned.")
print("Raw data returned:", json.dumps(data, indent=2))
except Exception as e:
print(f"Error fetching {curr}: {e}")
if __name__ == "__main__":
asyncio.run(run())

View File

@ -19,6 +19,8 @@ import { Progress } from "@/components/ui/progress"
import { formatTimestamp } from "@/lib/formatters"
import { BloombergView } from "@/components/bloomberg-view"
import { HeaderPortal } from "@/components/header-portal"
import { AppSidebar } from "@/components/app-sidebar"
import { StockChart } from "@/components/stock-chart"
export default function Home() {
const searchParams = useSearchParams()
@ -27,18 +29,23 @@ export default function Home() {
const [selectedCompany, setSelectedCompany] = useState<SearchResult | null>(null)
const [selectedDataSource, setSelectedDataSource] = useState<string>("iFinD")
const [companyId, setCompanyId] = useState<number | null>(null)
const [showFinancialData, setShowFinancialData] = useState(false)
const [analysisId, setAnalysisId] = useState<number | null>(null)
const [oneTimeModel, setOneTimeModel] = useState<string | undefined>(undefined)
const [currency, setCurrency] = useState<string>("Auto")
// View State (home, financial, chart)
const [currentView, setCurrentView] = useState<string>("home")
// 处理公司选择
const handleCompanySelect = (company: SearchResult, dataSource?: string) => {
setSelectedCompany(company)
setCompanyId(null)
setShowFinancialData(false)
setAnalysisId(null)
setOneTimeModel(undefined)
// Switch to financial view by default when company is selected
setCurrentView("financial")
// 如果没有传入数据源,则根据市场设置默认值
const targetDataSource = dataSource || (company.market === 'CN' ? 'Tushare' : 'iFinD')
setSelectedDataSource(targetDataSource)
@ -64,7 +71,6 @@ export default function Home() {
// 数据准备就绪
const handleDataReady = (id: number) => {
setCompanyId(id)
setShowFinancialData(true)
}
// AI分析完成
@ -72,20 +78,30 @@ export default function Home() {
setAnalysisId(id)
}
// 返回搜索
// 返回搜索 (Reset)
const handleBackToSearch = () => {
setSelectedCompany(null)
setCompanyId(null)
setShowFinancialData(false)
setAnalysisId(null)
setOneTimeModel(undefined)
setCurrentView("home")
}
// Navigation Handler
const handleTabChange = (tab: string) => {
if (tab === "home") {
handleBackToSearch()
} else {
setCurrentView(tab)
}
}
// Render Content based on View
const renderContent = () => {
// Home View
if (!selectedCompany || currentView === "home") {
return (
<div className="min-h-screen w-full p-8">
{/* 标题和描述 */}
{!selectedCompany && (
<div className="max-w-7xl mx-auto flex flex-col gap-8">
<div className="max-w-7xl mx-auto flex flex-col gap-8 p-8">
<div className="flex flex-col gap-4">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">
@ -98,160 +114,220 @@ export default function Home() {
<SearchStockWithSelection onSelect={handleCompanySelect} />
</div>
{/* 最近的报告 */}
<div className="flex flex-col gap-4 mt-8">
<h2 className="text-2xl font-bold tracking-tight"></h2>
<HistoryList />
{/* 历史记录 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<HistoryList onSelect={handleCompanySelect} />
</div>
</div>
)}
{selectedCompany && (
<div className="w-full">
<CompanyAnalysisView
company={selectedCompany}
dataSource={selectedDataSource}
onBack={handleBackToSearch}
onDataSourceChange={setSelectedDataSource}
oneTimeModel={oneTimeModel}
/>
</div>
)}
</div>
)
}
// 拆分出单独的组件以使用Hooks
// Chart View
if (currentView === "chart") {
return (
<div className="h-full p-4 flex flex-col gap-4">
{/* Top Navigation for Chart View */}
<div className="flex flex-row items-center justify-between px-2">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBackToSearch} className="gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{selectedCompany.company_name}</h1>
<Badge variant="outline">{selectedCompany.symbol}</Badge>
<Badge>{selectedCompany.market}</Badge>
</div>
</div>
</div>
<StockChart symbol={selectedCompany.symbol} market={selectedCompany.market} />
</div>
)
}
// Financial Data View (Default fallback)
return (
<div className="w-full pl-2 pr-32 py-6 space-y-6">
{/* 顶部导航栏 (Portal Target) */}
<div className="grid grid-cols-1 gap-6">
{/* 数据源选择 */}
<div className="flex flex-col gap-4">
<div className="hidden">
<DataSourceSelector
market={selectedCompany.market}
selectedSource={selectedDataSource}
onSourceChange={setSelectedDataSource}
/>
</div>
{/* 数据获取状态 */}
<CompanyAnalysisView
company={selectedCompany}
dataSource={selectedDataSource}
onDataReady={handleDataReady}
onAnalysisComplete={handleAnalysisComplete}
selectedModel={oneTimeModel}
currency={currency}
setCurrency={setCurrency}
/>
</div>
</div>
</div>
)
}
return (
<div className="flex h-screen w-full bg-background overflow-hidden">
{/* Sidebar */}
<AppSidebar
activeTab={currentView}
onTabChange={handleTabChange}
hasSelectedCompany={!!selectedCompany}
className="flex-shrink-0 z-10"
/>
{/* Main Content Area */}
<main className="flex-1 overflow-auto bg-background/50 relative">
{renderContent()}
</main>
</div>
)
}
// ----------------------------------------------------------------------
// Sub-components
// ----------------------------------------------------------------------
function CompanyAnalysisView({
company,
dataSource,
onBack,
onDataSourceChange,
oneTimeModel
onDataReady,
onAnalysisComplete,
selectedModel,
currency,
setCurrency
}: {
company: SearchResult,
dataSource: string,
onBack: () => void,
onDataSourceChange: (ds: string) => void,
oneTimeModel?: string
company: SearchResult
dataSource: string
onDataReady: (id: number) => void
onAnalysisComplete: (id: number) => void
selectedModel?: string
currency: string
setCurrency: (c: string) => void
}) {
const {
status,
loading,
fetching,
updateStatus,
error,
fetchData,
checkStatus
checkStatus,
updateStatus
} = useFinancialData(company, dataSource)
const [companyId, setCompanyId] = useState<number | null>(null)
const [analysisId, setAnalysisId] = useState<number | null>(null)
const progress = updateStatus ? {
percentage: updateStatus.progress_percentage || 0,
message: updateStatus.progress_message || ""
} : null
// 当数据就绪时设置companyId
useEffect(() => {
if (status?.has_data && status.company_id) {
setCompanyId(status.company_id)
} else {
setCompanyId(null)
if (status && status.has_data && status.company_id) {
onDataReady(status.company_id)
}
}, [status])
const handleAnalysisComplete = (id: number) => {
setAnalysisId(id)
}
const isUpdating = fetching && updateStatus
const progress = updateStatus?.progress_percentage || 0
const progressStatus = updateStatus?.progress_message || ""
}, [status, onDataReady])
return (
<div className="space-y-6">
{/* Header Controls */}
<HeaderPortal>
<div className="flex items-center justify-end w-full gap-4 text-sm h-full">
{/* 1. 状态显示 (进度或最后更新) */}
<div className="flex items-center text-muted-foreground mr-auto">
{isUpdating ? (
<div className="flex items-center gap-2 text-xs font-mono bg-muted/50 rounded-full px-3 py-1 animate-pulse">
<span className="font-bold text-primary">{Math.round(progress)}%</span>
<span className="truncate max-w-[200px]">{progressStatus || "Processing..."}</span>
<div className="flex items-center gap-2 mr-4 border-r pr-4">
<span className="font-bold whitespace-nowrap">{company.company_name}</span>
<Badge variant="outline" className="font-mono">{company.symbol}</Badge>
<Badge className="text-xs">{company.market}</Badge>
</div>
) : (
status?.has_data && status.last_update && (
<div className="flex items-center gap-1.5 text-xs opacity-80">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
<span>: {formatTimestamp(status.last_update.date)}</span>
</div>
)
)}
</div>
{/* 2. 公司基础信息 */}
<div className="flex items-center gap-3 border-l pl-4 border-r pr-4 h-8">
<span className="font-bold truncate max-w-[200px]">{company.company_name}</span>
<Badge variant="secondary" className="font-mono h-6">{company.market} {company.symbol}</Badge>
</div>
{/* 3. 操作按钮 (刷新) */}
<div className="flex items-center gap-2 mr-4">
<Button
onClick={() => fetchData(true)}
disabled={fetching}
variant={!status?.has_data ? "default" : "outline"}
variant="outline"
size="sm"
className="h-8"
onClick={() => fetchData(true, currency)}
disabled={loading || fetching}
className="gap-2"
>
{fetching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-2" />
) : (
<RefreshCw className="h-3.5 w-3.5 mr-2" />
)}
{status?.has_data ? "更新数据" : "获取数据"}
{(loading || fetching) ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{(loading || fetching) ? "更新中..." : "更新数据"}
</Button>
</div>
<div className="flex items-center space-x-1 mr-6 border rounded-md p-1 bg-muted/20">
{["Auto", "CNY", "USD"].map((opt) => (
<button
key={opt}
onClick={() => setCurrency(opt)}
className={`
px-3 py-1 text-xs font-medium rounded-sm transition-all
${currency === opt
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"}
`}
>
{opt}
</button>
))}
</div>
{fetching && (
<div className="flex items-center gap-2 mr-4 min-w-[200px]">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{progress?.message || "准备中..."}
</span>
<Progress value={progress?.percentage || 0} className="h-2 w-20" />
</div>
)}
{!fetching && !loading && <div className="text-xs text-muted-foreground flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-500" /> : {status?.last_update?.date ? formatTimestamp(status.last_update.date) : "无记录"}
</div>}
</HeaderPortal>
{/* 数据状态详情 (Card) */}
{/* DataStatusCard usage removed because it duplicates logic and caused prop mismatch.
Status is now handled by HeaderPortal controls.
*/}
{error && (
<div className="p-4 border border-destructive/50 bg-destructive/10 rounded-lg text-destructive flex items-center gap-2">
<Loader2 className="h-4 w-4" /> {/* Reuse icon or AlertCircle */}
<span>{error}</span>
</div>
)}
{/* 财务数据表格 */}
{status?.has_data && companyId && (
dataSource === 'Bloomberg' ? (
{dataSource === 'Bloomberg' ? (
status?.company_id && (
<BloombergView
companyId={companyId}
selectedCurrency={currency}
userMarket={company.market}
companyId={status.company_id}
lastUpdate={status.last_update?.date}
/>
)
) : (
status?.company_id && (
<FinancialTables
companyId={companyId}
companyId={status.company_id}
dataSource={dataSource}
/>
)
)}
{/* AI 分析触发器 */}
{status?.has_data && companyId && !analysisId && (
<AnalysisTrigger
companyId={companyId}
dataSource={dataSource}
model={oneTimeModel}
onAnalysisComplete={handleAnalysisComplete}
/>
)}
{/* AI 分析报告 */}
{analysisId && (
<AnalysisReport analysisId={analysisId} />
)}
</div>
)
}
// 搜索组件的包装器,添加选择功能
function SearchStockWithSelection({ onSelect }: { onSelect: (company: SearchResult) => void }) {
function SearchStockWithSelection({ onSelect }: { onSelect: (c: SearchResult, s?: string) => void }) {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<SearchStock onCompanySelect={onSelect} />

View File

@ -0,0 +1,41 @@
import { Home, LineChart, BarChart3, Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface AppSidebarProps {
activeTab: string
onTabChange: (tab: string) => void
className?: string
hasSelectedCompany: boolean
}
export function AppSidebar({ activeTab, onTabChange, className, hasSelectedCompany }: AppSidebarProps) {
const navItems = [
{ id: "home", label: "首页", icon: Home, disabled: false },
{ id: "financial", label: "财务数据", icon: BarChart3, disabled: !hasSelectedCompany },
{ id: "chart", label: "股价图", icon: LineChart, disabled: !hasSelectedCompany },
]
return (
<div className={cn("w-64 border-r bg-card flex flex-col p-4 gap-2", className)}>
{navItems.map((item) => (
<Button
key={item.id}
variant={activeTab === item.id ? "secondary" : "ghost"}
className={cn(
"justify-start gap-3 h-12 text-base font-normal",
activeTab === item.id && "bg-secondary font-medium",
activeTab !== item.id && "text-muted-foreground"
)}
onClick={() => !item.disabled && onTabChange(item.id)}
disabled={item.disabled}
>
<item.icon className="h-5 w-5" />
{item.label}
</Button>
))}
</div>
)
}

View File

@ -12,14 +12,19 @@ import type { FinancialDataResponse } from "@/lib/types"
interface BloombergViewProps {
companyId: number
onBack?: () => void
selectedCurrency?: string
userMarket?: string
lastUpdate?: string
}
export function BloombergView({ companyId, onBack }: BloombergViewProps) {
export function BloombergView({ companyId, onBack, selectedCurrency = "Auto", userMarket, lastUpdate }: BloombergViewProps) {
const [data, setData] = useState<FinancialDataResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const loadData = async () => {
if (!companyId) return
setLoading(true)
setError("")
try {
@ -37,7 +42,7 @@ export function BloombergView({ companyId, onBack }: BloombergViewProps) {
useEffect(() => {
loadData()
}, [companyId])
}, [companyId, lastUpdate])
if (loading) {
return (
@ -67,24 +72,177 @@ export function BloombergView({ companyId, onBack }: BloombergViewProps) {
const mergedData = data.unified_data || []
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold flex items-center gap-2">
<div className="space-y-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-6">
<h2 className="text-2xl font-bold flex items-center gap-2 whitespace-nowrap">
<DollarSign className="w-6 h-6" />
Bloomberg
</h2>
{/* Insert Inline Header Info here */}
<BasicInfoHeader data={mergedData} selectedCurrency={selectedCurrency} userMarket={userMarket} />
</div>
<div className="flex gap-2 ml-auto">
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
<RawDataTable title="财务数据总表" data={mergedData} />
<RawDataTable title="财务数据总表" data={mergedData} selectedCurrency={selectedCurrency} userMarket={userMarket} />
</div>
)
}
function RawDataTable({ data, title }: { data: any[], title: string }) {
// ---------------------------------------------------------------------------
// Basic Info Header Component
// ---------------------------------------------------------------------------
function BasicInfoHeader({ data, selectedCurrency = "Auto", userMarket }: { data: any[], selectedCurrency?: string, userMarket?: string }) {
if (!data || data.length === 0) return null
// 1. Determine Target Currency (Logic shared with Table)
let targetCurrency = selectedCurrency
if (targetCurrency === "Auto" && userMarket) {
const market = userMarket.toUpperCase()
if (market.includes("JP")) targetCurrency = "JPY"
else if (market.includes("VN")) targetCurrency = "VND"
else if (market.includes("CN")) targetCurrency = "CNY"
else if (market.includes("HK")) targetCurrency = "HKD"
else targetCurrency = "USD"
}
// 2. Find Latest Valid Data for each field
// We sort data by date descending to get latest first.
// Note: 'end_date' is the reporting period, 'update_date' is when it was fetched.
// Usually PE/PB/MarketCap are "current" values, so they might be associated with the latest reporting period or a special "current" row.
// Based on bloomberg logic, they are saved as time series. We take the latest available.
// Sort rows by date descending
const sortedRows = [...data].sort((a, b) => new Date(b.end_date).getTime() - new Date(a.end_date).getTime())
// Helper to find first non-null value
const findValue = (keys: string[]) => {
for (const row of sortedRows) {
// Check currency match if required (PE/PB/MarketCap are currency dependent usually, or at least MarketCap is)
// However, PE/PB are ratios.
// Strict currency check for everything to be safe?
if (targetCurrency !== 'Auto') {
if (row.currency && row.currency !== targetCurrency) continue
} else {
// If auto, try to match first row's currency if established, or just take any?
// Let's stick to the targetCurrency logic used in Table which resolved 'Auto' to a specific one if possible.
}
for (const key of keys) {
if (row[key] !== null && row[key] !== undefined && row[key] !== '') {
return { value: row[key], row }
}
}
}
return null
}
// Extract Fields
const companyName = findValue(['company_name'])?.value || '-'
// Find latest update_date from ALL rows matching currency
let maxUpdateDate = ''
for (const row of data) {
if (targetCurrency !== 'Auto' && row.currency && row.currency !== targetCurrency) continue
if (row.update_date && row.update_date > maxUpdateDate) {
maxUpdateDate = row.update_date
}
}
const updateDate = maxUpdateDate
// update_date is a timestamp string "2023-10-xx 10:00:00". User wants "Day only".
const displayDate = updateDate ? formatDate(updateDate) : '-'
const pe = findValue(['pe', 'pe_ratio'])?.value
const pb = findValue(['pb', 'pb_ratio'])?.value
const marketCapTuple = findValue(['market_cap'])
const marketCap = marketCapTuple?.value
const marketCapCurrency = marketCapTuple?.row?.currency || targetCurrency
const abroadRev = findValue(['rev_abroad', 'pct_revenue_from_foreign_sources'])?.value
const divYield = findValue(['dividend_yield', 'dividend_12_month_yield'])?.value
const ipoDateRaw = findValue(['ipo_date', 'IPO_date', 'eqy_init_po_dt'])?.value
const ipoDate = ipoDateRaw ? formatDate(ipoDateRaw) : '-'
// Formatters
const formatRatio = (val: any) => {
if (val === undefined || val === null) return '-'
const num = parseFloat(val)
if (isNaN(num)) return '-'
return num.toFixed(2)
}
const formatPercent = (val: any) => {
if (val === undefined || val === null) return '-'
const num = parseFloat(val)
if (isNaN(num)) return '-'
return num.toFixed(2) + '%'
}
const formatMoney = (val: any, currency: string) => {
if (val === undefined || val === null) return '-'
let num = parseFloat(val)
if (isNaN(num)) return '-'
// Backend now returns Market Cap in Millions for ALL currencies (via BDH).
// To convert Millions to "Yi" (100 Million), we divide by 100.
// This applies uniformly to JPY, USD, CNY, etc.
num = num / 100
return num.toLocaleString('en-US', { maximumFractionDigits: 2 }) + ' 亿'
}
return (
<div className="flex items-center gap-6 text-sm bg-muted/30 px-4 py-2 rounded-lg border">
{/* Group 1: Identity */}
<div className="flex flex-col gap-0.5 border-r pr-6">
<span className="font-bold text-lg text-primary truncate max-w-[200px]" title={companyName}>{companyName}</span>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>IPO: {ipoDate}</span>
<span>: {displayDate}</span>
</div>
</div>
{/* Group 2: Key Ratios */}
<div className="flex items-center gap-6">
<div className="flex flex-col items-center">
<span className="text-muted-foreground text-xs scale-90">PE</span>
<span className="font-mono font-bold text-base">{formatRatio(pe)}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-muted-foreground text-xs scale-90">PB</span>
<span className="font-mono font-bold text-base">{formatRatio(pb)}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-muted-foreground text-xs scale-90"></span>
<span className="font-mono font-bold text-base">{formatPercent(divYield)}</span>
</div>
</div>
{/* Group 3: Market Size & Biz */}
<div className="flex items-center gap-6 border-l pl-6">
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs scale-90"> ({marketCapCurrency})</span>
<span className="font-mono font-bold text-base text-blue-600 dark:text-blue-400">{formatMoney(marketCap, marketCapCurrency)}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs scale-90"></span>
<span className="font-mono font-bold text-base">{formatPercent(abroadRev)}</span>
</div>
</div>
</div>
)
}
function RawDataTable({ data, title, selectedCurrency = "Auto", userMarket }: { data: any[], title: string, selectedCurrency?: string, userMarket?: string }) {
if (!data || data.length === 0) {
return (
<Card>
@ -98,12 +256,53 @@ function RawDataTable({ data, title }: { data: any[], title: string }) {
)
}
// ---------------------------------------------------------------------------
// 1. Filter Data Rows (Handle Multi-Currency) - DO THIS FIRST!
// ---------------------------------------------------------------------------
// Determine Target Currency Strategy
let targetCurrency = selectedCurrency
if (targetCurrency === "Auto" && userMarket) {
const market = userMarket.toUpperCase()
if (market.includes("JP")) targetCurrency = "JPY"
else if (market.includes("VN")) targetCurrency = "VND"
else if (market.includes("CN")) targetCurrency = "CNY"
else if (market.includes("HK")) targetCurrency = "HKD"
else targetCurrency = "USD"
}
const filteredRows = data.filter(row => {
// Basic Validity Check
if (!row || row.revenue === null || row.revenue === undefined) return false
// Currency Filtering
// Case 1: Specific Currency Selected (or resolved from Auto)
if (targetCurrency !== "Auto") {
if (row.currency && row.currency !== targetCurrency) {
return false
}
}
// Case 2: Pure Auto (Fallback if no userMarket)
else if (selectedCurrency === "Auto") {
if (data.length > 0 && data[0].currency && row.currency && row.currency !== data[0].currency) {
return false
}
}
return true
})
// ---------------------------------------------------------------------------
// 2. Extract Indicators (From Filtered Data ONLY)
// ---------------------------------------------------------------------------
// 提取所有指标键值 (排除元数据)
const excludeKeys = ['id', 'company_code', 'code', 'symbol', 'market', 'update_date', 'create_time', 'end_date', 'ts_code']
// 从合并后的数据中收集所有可能的指标
const allIndicators = new Set<string>()
data.forEach(row => {
filteredRows.forEach(row => {
Object.keys(row).forEach(k => {
if (!excludeKeys.includes(k)) {
allIndicators.add(k)
@ -166,19 +365,29 @@ function RawDataTable({ data, title }: { data: any[], title: string }) {
return a.localeCompare(b)
})
// ---------------------------------------------------------------------------
// 1. Filter Data Rows (Handle Multi-Currency)
// ---------------------------------------------------------------------------
// We must filter BEFORE creating the Map, because multiple rows might exist
// for the same date (e.g. 2023 JPY and 2023 USD).
// The Map key is 'end_date', so it can only hold one currency's data per date.
// ---------------------------------------------------------------------------
// 2. Build Data Structures from Filtered Rows
// ---------------------------------------------------------------------------
// 构建查找表: Map<DateString, RowData>
const dataMap = new Map()
data.forEach(row => {
filteredRows.forEach(row => {
dataMap.set(row.end_date, row)
})
// 过滤日期: 只保留有营业收入 (revenue) 的列
const dates = Array.from(new Set(data.map(row => row.end_date)))
.filter(date => {
const row = dataMap.get(date)
// Check if revenue exists and is not null/undefined
return row && row.revenue !== null && row.revenue !== undefined
})
// 提取日期列表
const dates = Array.from(new Set(filteredRows.map(row => row.end_date)))
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
@ -365,7 +574,7 @@ function RawDataTable({ data, title }: { data: any[], title: string }) {
}
return (
<TableCell key={date} className={`text-right px-4 font-mono ${highlightClass} ${textStyle}`}>
<TableCell key={date} className={`text-right px-4 tabular-nums ${highlightClass} ${textStyle}`} style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }}>
{formatCellValue(indicator, value)}
</TableCell>
)

View File

@ -155,7 +155,7 @@ function FinancialTable({ data, title }: FinancialTableProps) {
{data.map((row, idx) => (
<TableRow key={idx}>
{columns.map((column) => (
<TableCell key={column} className="font-mono text-right">
<TableCell key={column} className="text-right tabular-nums" style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }}>
{formatCellValue(column, row[column])}
</TableCell>
))}
@ -220,7 +220,7 @@ function TransposedFinancialTable({ data, title }: FinancialTableProps) {
</TableCell>
{/* 值 */}
{sortedData.map((row, rowIdx) => (
<TableCell key={rowIdx} className="font-mono text-right px-4">
<TableCell key={rowIdx} className="text-right px-4 tabular-nums" style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }}>
{formatCellValue(indicator, row[indicator])}
</TableCell>
))}

View File

@ -1,13 +1,17 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { getReports } from "@/lib/api"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Loader2 } from "lucide-react"
import type { SearchResult } from "@/lib/types"
export function HistoryList() {
interface HistoryListProps {
onSelect?: (company: SearchResult, dataSource?: string) => void
}
export function HistoryList({ onSelect }: HistoryListProps) {
const [reports, setReports] = useState<any[]>([])
const [loading, setLoading] = useState(true)
@ -22,8 +26,19 @@ export function HistoryList() {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{reports.map((report: any) => (
<Link key={report.id} href={`/analysis/${report.id}`}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
<Card
key={report.id}
className="hover:shadow-lg transition-shadow cursor-pointer"
onClick={() => {
if (onSelect) {
onSelect({
symbol: report.symbol,
market: report.market,
company_name: report.company_name
}, report.data_source)
}
}}
>
<CardHeader>
<CardTitle className="text-lg">{report.company_name}</CardTitle>
<CardDescription>
@ -52,7 +67,6 @@ export function HistoryList() {
)}
</CardContent>
</Card>
</Link>
))}
</div>
)

View File

@ -105,27 +105,27 @@ export function NavHeader() {
</Select>
<Select onValueChange={handleCompanyChange}>
<SelectTrigger className="w-[240px] h-8 text-xs font-mono">
<SelectValue placeholder="选择最近浏览的代码..." />
<SelectTrigger className="w-[120px] h-8 text-xs font-mono">
<SelectValue placeholder="代码..." />
</SelectTrigger>
<SelectContent className="z-[70]">
{companies.map((c) => (
<SelectItem key={`${c.symbol}:${c.market}`} value={`${c.symbol}:${c.market}`}>
<span className="flex items-center gap-2 w-full">
<span className="font-bold w-[60px] text-left">{c.symbol}</span>
<span className="font-bold w-[50px] text-left">{c.symbol}</span>
<span className="truncate flex-1 text-left">{c.company_name}</span>
</span>
</SelectItem>
))}
{companies.length === 0 && (
<div className="p-2 text-xs text-muted-foreground text-center"></div>
<div className="p-2 text-xs text-muted-foreground text-center"></div>
)}
</SelectContent>
</Select>
</div>
<div className="w-[300px] ml-6">
<div className="w-[160px] ml-4">
<HeaderSearch defaultDataSource={dataSource} />
</div>

View File

@ -0,0 +1,83 @@
"use client"
import { useEffect, useRef, useState } from "react"
interface StockChartProps {
symbol: string
market: string
}
export function StockChart({ symbol, market }: StockChartProps) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
// Clear previous widget
containerRef.current.innerHTML = ""
// Create wrapper for widget
const widgetContainer = document.createElement("div")
widgetContainer.className = "tradingview-widget-container__widget h-full w-full"
containerRef.current.appendChild(widgetContainer)
const script = document.createElement("script")
script.src = "https://s3.tradingview.com/external-embedding/embed-widget-advanced-chart.js"
script.type = "text/javascript"
script.async = true
// Map Market/Symbol to TradingView format
let exchange = "NASDAQ"
let tvSymbol = symbol
if (market === "CN") {
if (symbol.startsWith("6")) exchange = "SSE"
else if (symbol.startsWith("0") || symbol.startsWith("3")) exchange = "SZSE"
else if (symbol.startsWith("4") || symbol.startsWith("8")) exchange = "BSE"
} else if (market === "HK") {
exchange = "HKEX"
// TradingView usually expects HK stocks without leading zeros if they are 4 digits, but let's check.
// Actually HKEX:700 works, HKEX:0700 might work too. Let's try to keep it safe.
tvSymbol = parseInt(symbol).toString()
} else if (market === "JP") {
exchange = "TSE"
} else if (market === "VN") {
exchange = "HOSE" // Primary VN exchange
} else {
// US
exchange = "NASDAQ" // Default, could be NYSE
// Basic heuristic for US
// If 4 chars or more, likely Nasdaq, <=3 likely NYSE? Not 100% accurate but acceptable default.
}
const fullSymbol = `${exchange}:${tvSymbol}`
script.innerHTML = JSON.stringify({
"autosize": true,
"symbol": fullSymbol,
"interval": "D",
"timezone": "Asia/Shanghai",
"theme": "light",
"style": "1",
"locale": "zh_CN",
"enable_publishing": false,
"allow_symbol_change": true,
"calendar": false,
"support_host": "https://www.tradingview.com"
})
containerRef.current.appendChild(script)
}, [symbol, market])
return (
<div className="h-[calc(100vh-100px)] w-full bg-background rounded-lg border overflow-hidden p-1">
<div className="tradingview-widget-container h-full w-full" ref={containerRef}>
<div className="tradingview-widget-copyright text-xs text-center text-muted-foreground p-2">
TradingView Chart by <a href="https://cn.tradingview.com/" rel="noopener nofollow" target="_blank">TradingView</a>
</div>
</div>
</div>
)
}

View File

@ -58,7 +58,7 @@ export function useFinancialData(company: SearchResult | null, dataSource: strin
}, [updateId, fetching, checkStatus])
// Trigger data fetch
const fetchData = async (forceRefresh = false) => {
const fetchData = async (forceRefresh = false, currency?: string) => {
if (!company) return
setFetching(true)
setError("")
@ -68,7 +68,8 @@ export function useFinancialData(company: SearchResult | null, dataSource: strin
symbol: company.symbol,
company_name: company.company_name,
data_source: dataSource,
force_refresh: forceRefresh
force_refresh: forceRefresh,
currency: currency
})
setUpdateId(response.update_id)
} catch (err: any) {

View File

@ -58,6 +58,7 @@ export interface FetchDataRequest {
company_name: string
data_source: string
force_refresh?: boolean
currency?: string
}
export interface FetchDataResponse {