Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,29 @@ live in `docs/`.
not establish market-data freshness.

### Fixed
- React Query resolves its consumer's React types explicitly, avoiding CI type failures caused by unrelated React 18 workspace types being hoisted into the React 19 frontend.
- Certified stored TTM statements remain available when the live provider has no usable source or calculation fails; rejected or conflicting provider statements still cannot regain withheld values through fallback merging.
- Empty or unusable TTM source results retain an explicit unavailable reason and null metrics; calculation failures no longer disappear into a generic empty response. Deployment smoke checks validate HTTPS certificates instead of bypassing trust.
- Deferred published tabs are discarded when Back/Forward leaves their workspace. Unsupported statement rows show their source reason instead of blank TTM sections, and date-only observations use a day-granular freshness window.
- Wide-row financial aliases pass through the same unit normalization and lineage checks as canonical metrics; a missing lineage entry cannot certify a monetary field as VND.
- `pnpm --filter frontend qa:live-smoke` discovers published workspaces and tabs, exercises click and direct navigation within bounded budgets, and reports pending/error bodies separately from navigation success.
- Deferred widget trees mount before visible DOM attachment, preventing cached lazy imports from stranding their Suspense fallback after a warm reload. Charts recover when a positive-size observation arrives after detached-mount retries are exhausted. Collapse still unmounts content; off-screen requests start earlier.
- Dashboard rendering uses the precomputed active layout and matching column count, removing the responsive grid cache that alternated medium projections with authored desktop geometry. Responsive projections remain unsaved.
- Unavailable financial-period reasons remain visible beside populated sibling rows. Hydrated news IDs retain their string identity, and unavailable heatmap universes no longer display zero-stock legend counts.
- Derived EMA, gamma, flow-regime and beta consumers withhold uncertified price inputs; source coverage remains separate from observation staleness, and missing sector changes no longer appear as neutral zero returns.
- Fiscal-year FX follows the same financial quantity through flow-chart formatting. Explicit FY aliases retain their real year; quarter rows are not relabelled as TTM, and unsupported TTM ratios stay unavailable.
- Fresh workspace deep links apply their requested tab after workspace selection without callback-driven restore loops. Late published tabs remain deferred until available and genuine user navigation cancels the pending request.
- Peer percentage normalization uses declared stored-fraction keys instead of magnitude guesses. Loaded-peer averages disclose their cohort, and backtest/sweep footers expose windows, fees, execution and Sharpe basis.
- News relevance requires boundary-safe ticker mentions and the analyzer's actual confidence scale; rule-based ranks are not presented as match probabilities.
- Charts require per-row price-unit declarations instead of trusting aggregate certification; unmarked sessions are withheld or retained as visible gaps. Unconfirmed quotes no longer drive mixed-unit 52-week ranges, and confirmed monetary quote deltas share the price's FX boundary.
- Source observation time is separate from retrieval, cached delivery and coverage in widget health, exports, news and data-source views. Missing news publication dates remain unknown; a new fetch does not make old data live.
- Fundamental valuation retains explicit price, statement and share-unit lineage in schema 2. Unknown units and conflicting fiscal observations suppress monetary comparisons; legacy snapshots remain unverified instead of receiving magnitude-based repairs.
- Widget search explains no matches and restores opener focus on Escape. Insider net values distinguish missing, genuine zero and signed amounts. Listing responses preserve stored industries, and empty heatmaps disclose unavailable coverage.
- The GET-only `scripts/qa_data_contract_smoke.py` checks served history envelopes and unit declarations without certifying provider values or rewriting data.
- Financial statements retain source-unit and fiscal-basis lineage. KBS amounts already normalized by the installed parser are no longer multiplied twice; TTM uses a balance snapshot or complete compatible quarterly flows, with explicit unavailable reasons for unknown units or ambiguous periods.
- Financial table and Growth Bridge use an absolute prior denominator for negative-base comparisons without changing the underlying annual earnings. Transition labels distinguish loss-to-profit from same-sign negative comparisons.
- Workspace URL writes remain scoped to the active workspace and carry router state; deferred symbols cannot freeze navigation after leaving their target workspace.
- Backtest and sweep use the same settled-session price basis and expose replay parameters. This alignment does not independently explain the originally reported Sharpe discrepancy.
- Quote/history price units are source-backed and canonical: certified equities use VND, indices/derivatives retain points, and unmarked legacy or sponsor data remains unknown. Explicit unit markers take precedence; prices are never classified by magnitude. Ingestion, cache reads and analytical consumers preserve provenance and disclose excluded unknown-unit history.
- The advanced chart retains deterministic API history, rejects unknown or incompatible quote units and old timestamps, and protects same-day adjusted bars from raw quote replacement. The observed `57.3`/`57300` mismatch no longer selects the smaller close. Derived market capitalization and valuation require confirmed VND prices.
- ATR Regime uses the historical observation date for visible freshness and runtime/export provenance; fetching old data does not relabel it live. Optional dashboard sync preserves linked `globalMarketsSymbol` and ticker `widgetGroups`, while imported workspaces stay local.
Expand Down
67 changes: 66 additions & 1 deletion apps/api/tests/test_api/quant_endpoint_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,10 @@ async def fake_load_quant_frame_with_warning(**kwargs):

@pytest.mark.asyncio
async def test_quant_sweep_returns_cells_and_best_combo(client, monkeypatch):
async def fake_load_quant_frame_with_warning(**_kwargs):
captured: dict = {}

async def fake_load_quant_frame_with_warning(**kwargs):
captured.update(kwargs)
frame = _build_price_frame(220)
midpoint = len(frame) // 2
frame.loc[:midpoint, "close"] = [120 - (index * 0.25) for index in range(midpoint + 1)]
Expand Down Expand Up @@ -292,6 +295,68 @@ async def fake_load_quant_frame_with_warning(**_kwargs):
assert data["best"]["fast_window"] in {5, 10}
assert data["best"]["slow_window"] in {20, 40}
assert payload["meta"]["count"] == 4
# Sweep must run on settled sessions, on the same basis as the backtest.
assert captured["include_latest_quote"] is False
assert data["basis"]["include_latest_quote"] is False
assert data["basis"]["adjustment_mode"] == "adjusted"
assert data["basis"]["fast_windows"] == [5, 10]
assert data["basis"]["slow_windows"] == [20, 40]


@pytest.mark.asyncio
async def test_sweep_and_backtest_share_sharpe_basis_on_settled_sessions(client, monkeypatch):
"""Same strategy/window over the same settled frame must yield the same Sharpe.

Regression for the sweep defaulting to include_latest_quote=True while the
backtest used False, which silently changed the Sharpe basis.
"""
def _settled_frame():
frame = _build_price_frame(220)
midpoint = len(frame) // 2
frame.loc[:midpoint, "close"] = [120 - (index * 0.25) for index in range(midpoint + 1)]
frame.loc[midpoint + 1 :, "close"] = [
85 + (index * 0.8) for index in range(len(frame) - midpoint - 1)
]
frame["open"] = frame["close"] - 0.2
frame["high"] = frame["close"] + 0.8
frame["low"] = frame["close"] - 0.8
return frame

async def fake_load_quant_frame_with_warning(**_kwargs):
return _settled_frame(), None

monkeypatch.setattr(
"vnibb.api.v1.quant._load_quant_frame_with_warning", fake_load_quant_frame_with_warning
)

backtest = await client.post(
"/api/v1/quant/VNM/backtest",
json={
"period": "1Y",
"as_of_date": "2026-03-13",
"initial_capital": 1000000,
"fee_bps": 10,
"strategy": {"type": "moving_average_crossover", "fast_window": 5, "slow_window": 20},
},
)
sweep = await client.post(
"/api/v1/quant/VNM/sweep",
json={
"period": "1Y",
"initial_capital": 1000000,
"fee_bps": 10,
"fast_windows": [5],
"slow_windows": [20],
"objective": "sharpe_daily_rf0",
},
)

assert backtest.status_code == 200
assert sweep.status_code == 200
backtest_sharpe = backtest.json()["data"]["metrics"]["sharpe_daily_rf0"]
sweep_cell = sweep.json()["data"]["cells"][0]
assert sweep_cell["fast_window"] == 5 and sweep_cell["slow_window"] == 20
assert sweep_cell["sharpe_daily_rf0"] == backtest_sharpe


@pytest.mark.asyncio
Expand Down
101 changes: 101 additions & 0 deletions apps/api/tests/test_api/test_comparison_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,104 @@ async def fake_fetch(params):
monkeypatch.setattr("vnibb.providers.vnstock.equity_historical.VnstockEquityHistoricalFetcher.fetch", fake_fetch)
result = await ComparisonService().compare_price_performance(["VCI", "VNINDEX", "UNKNOWN"])
assert result[-1].values == {"VCI": pytest.approx(110.0), "VNINDEX": pytest.approx(110.0)}


def test_normalize_comparison_metric_never_infers_a_magnitude():
"""#106: stored comparison percents are canonical; no magnitude heuristics.

The canonical percent values are already scaled (the derived backfill
multiplies by 100). A stored fraction must not be silently promoted, and an
out-of-bound value is unknown rather than divided down to fit.
"""
from vnibb.services.comparison_service import _normalize_comparison_metric

# Whole-unit percent stays exactly as stored (fixture unit is whole).
assert _normalize_comparison_metric("ocf_sales", 1) == 1
assert _normalize_comparison_metric("ocf_sales", 21.5) == 21.5
# A fraction is no longer promoted to 100x.
assert _normalize_comparison_metric("roe", 0.25) == 0.25
# A value above the plausible percent bound is unknown, not rescaled.
assert _normalize_comparison_metric("roe", 25_000.0) is None
# Non-numeric and missing stay missing.
assert _normalize_comparison_metric("roe", None) is None
assert _normalize_comparison_metric("roe", "") is None


def test_ratio_json_percent_keys_follow_the_writer_fraction_contract():
"""#106: raw-JSON `ocf_sales`/`fcf_yield` are stored as fractions.

`_build_ratio_enrichment` writes `operating_cash_flow / revenue` and
`free_cash_flow / market_cap`, while the comparison contract declares
`format="percent"`. The read site converts by key name so one metric cannot
render 100x apart depending on which fallback supplied it. No other key and
no column is rescaled.
"""
from types import SimpleNamespace

from vnibb.services.comparison_service import _metric_from_ratio_row, _ratio_json_metric

raw_data = {"ocf_sales": 0.001, "fcf_yield": 0.052, "roe": 0.25}
assert _ratio_json_metric("ocf_sales", raw_data, "ocfSales") == pytest.approx(0.1)
assert _ratio_json_metric("fcf_yield", raw_data, "fcfYield") == pytest.approx(5.2)
# A key with no fraction writer keeps its stored magnitude.
assert _ratio_json_metric("roe", raw_data) == pytest.approx(0.25)

row = SimpleNamespace(pe_ratio=12.0, raw_data=raw_data)
assert _metric_from_ratio_row(row, "ocf_sales") == pytest.approx(0.1)
# A column value is returned as stored, never rescaled.
assert _metric_from_ratio_row(row, "pe") == pytest.approx(12.0)


def test_ttm_statement_snapshot_requires_consecutive_certified_single_quarters():
"""#106: a four-row sum is only TTM when the rows are certified consecutive quarters."""
from types import SimpleNamespace

from vnibb.services.comparison_service import _build_statement_snapshot

def quarter(year: int, quarter_number: int, revenue: float, *, flow_basis="single_quarter", unit="VND"):
return SimpleNamespace(
fiscal_year=year,
fiscal_quarter=quarter_number,
revenue=revenue,
gross_profit=None,
operating_income=None,
net_income=None,
interest_expense=None,
operating_cash_flow=None,
free_cash_flow=None,
debt_repayment=None,
raw_data={"_financial_lineage": {"value_unit": unit, "flow_basis": flow_basis}},
)

# `_load_statement_rows` orders `desc(fiscal_year), desc(fiscal_quarter)`, so the
# window arrives newest-first; the TTM guard reads it in that order.
consecutive = [
quarter(2024, 4, 40.0),
quarter(2024, 3, 30.0),
quarter(2024, 2, 20.0),
quarter(2024, 1, 10.0),
]
assert _build_statement_snapshot(consecutive, "income", ttm=True)["revenue"] == pytest.approx(100.0)

# A cumulative YTD series is not a four-quarter flow; summing it double counts.
# Same window, so only the basis guard can reject it.
cumulative = [quarter(2024, 4, 400.0, flow_basis="cumulative_ytd"), *consecutive[1:]]
assert _build_statement_snapshot(cumulative, "income", ttm=True) is None

# A legacy row with no certified unit lineage is withheld, not summed.
unknown_unit = [quarter(2024, 4, 40.0, unit="unknown"), *consecutive[1:]]
assert _build_statement_snapshot(unknown_unit, "income", ttm=True) is None

# A gap means the window is not four consecutive quarters.
gapped = [consecutive[0], consecutive[1], quarter(2023, 2, 20.0), quarter(2023, 1, 10.0)]
assert _build_statement_snapshot(gapped, "income", ttm=True) is None

# The balance sheet is a latest-quarter snapshot in every mode, never a sum.
balance = SimpleNamespace(
fiscal_year=2024,
fiscal_quarter=4,
total_assets=1.0,
total_liabilities=2.0,
total_equity=3.0,
)
assert _build_statement_snapshot([balance], "balance", ttm=True)["total_assets"] == pytest.approx(1.0)
Loading
Loading