diff --git a/README.md b/README.md index 5462625..02311d9 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ For a server deployment, bind to the network interface with `--host 0.0.0.0` and The cumulative daily ZIP is saved automatically as `data/raw/siamchart/set-archive_EOD_LAST.zip`; the two historical ZIPs remain the warm-up and backtest sources. The dashboard displays the stored session range and the source used for its latest date. **ลงทุนครั้งแรก** records the initial cash contribution; later contributions increase cash and recalculate target holdings from current portfolio equity and the model weights. The app shows current shares, target shares and proposed BUY/SELL differences. Click **บันทึกซื้อ** or **บันทึกขาย** beside a daily signal to add its proposed share quantity to the local portfolio ledger at the latest EOD close. This only updates the app; it does not send a broker order. Use **แก้** in the recent history after your order fills to update the quantity, actual price and fees, or **ยกเลิก** to remove an estimated signal entry from portfolio calculations while retaining its history. Record dividends and withdrawals in the cash ledger too; share splits and rights entitlements still need manual reconciliation. Account history is persisted in `data/portfolio/prime_thai_portfolio.sqlite3`, which is included as the initial database snapshot. Keep this file on persistent server storage so portfolio edits survive redeploys; later server-side changes do not sync back to Git automatically. -When the available cash cannot fund the whole target basket, the dashboard uses a capital-aware lot allocator. It ranks names selected by both models first, then uses the model signal score, and only recommends whole 100-share lots that fit the current cash. Names that do not fit remain visible as **งบไม่พอ** instead of being silently chosen by ticker order. A recommendation that was recorded as a one-lot purchase remains held while its signal is still active, even if equal-weight sizing would round below one lot on the next refresh. The budget is based on currently recorded cash; expected sale proceeds become available after the sale is recorded. +When the available cash cannot fund the whole target basket, the dashboard uses a capital-aware lot allocator. It ranks names selected by both models first, then uses a combined momentum and volatility-adjusted momentum score, and only recommends whole 100-share lots that fit the current cash. Names that do not fit remain visible as **งบไม่พอ** instead of being silently chosen by ticker order. A recommendation that was recorded as a one-lot purchase remains held while its signal is still active, even if equal-weight sizing would round below one lot on the next refresh. The budget is based on currently recorded cash; expected sale proceeds become available after the sale is recorded. The proposed limit reference is the latest EOD close, not a live quote or a guaranteed fill. Signal entries use that close as an estimate until you edit the ledger with the actual fill. The app does not connect to a broker, transmit orders, or automatically reconcile broker holdings. The backtest page is a research simulation, not a validated live record, and does not model market impact, bid/ask spreads, every corporate action, or broker-specific buying power. Verify current quotes, liquidity, order size and cash with the broker before each order. diff --git a/primethai/model_lab.py b/primethai/model_lab.py index 304d83d..7c469ff 100644 --- a/primethai/model_lab.py +++ b/primethai/model_lab.py @@ -558,7 +558,7 @@ def build_next_session_union_signals( return pd.DataFrame(columns=[ "signal_as_of", "for_session", "symbol", "source_model", "target_weight", "reference_close", "indicative_shares_at_last_close", "last_bar_date", "stale_sessions", - "model_agreement", "signal_score", + "model_agreement", "signal_score", "risk_adjusted_score", "allocation_score", ]) # Rank selected names by the model's own momentum signal. This gives the # live budget allocator a stable quality score instead of using ticker order. @@ -578,6 +578,17 @@ def build_next_session_union_signals( str(symbol): float(1.0 - (rank / count) + (1.0 / count)) for symbol, rank in ranks.items() } + risk_values = pd.to_numeric( + features["risk_adjusted_momentum"].iloc[-1].reindex(selected), errors="coerce" + ).dropna() + normalized_risk: dict[str, float] = {} + if not risk_values.empty: + risk_ranks = risk_values.rank(method="min", ascending=False) + risk_count = float(len(risk_values)) + normalized_risk = { + str(symbol): float(1.0 - (rank / risk_count) + (1.0 / risk_count)) + for symbol, rank in risk_ranks.items() + } weight = 1.0 / len(selected) latest_date = panel.dates[-1] @@ -592,6 +603,9 @@ def build_next_session_union_signals( if bool(ema[symbol]): source.append("ema50_200_cross") model_scores = [normalized_scores[name].get(str(symbol), 0.0) for name in source] + signal_score = sum(model_scores) / len(model_scores) if model_scores else 0.0 + risk_score = normalized_risk.get(str(symbol), 0.0) + allocation_score = math.sqrt(max(0.0, signal_score) * max(0.0, risk_score)) bar_dates = panel.dates[panel.has_bar[symbol].to_numpy(dtype=bool)] last_bar = bar_dates[-1] if len(bar_dates) else pd.NaT stale_sessions = int((panel.dates > last_bar).sum()) if not pd.isna(last_bar) else len(panel.dates) @@ -606,11 +620,13 @@ def build_next_session_union_signals( "last_bar_date": last_bar.date().isoformat() if not pd.isna(last_bar) else "", "stale_sessions": stale_sessions, "model_agreement": len(source), - "signal_score": sum(model_scores) / len(model_scores) if model_scores else 0.0, + "signal_score": signal_score, + "risk_adjusted_score": risk_score, + "allocation_score": allocation_score, }) return pd.DataFrame(rows).sort_values( - ["model_agreement", "signal_score", "source_model", "symbol"], - ascending=[False, False, True, True], kind="mergesort" + ["model_agreement", "allocation_score", "signal_score", "source_model", "symbol"], + ascending=[False, False, False, True, True], kind="mergesort" ).reset_index(drop=True) diff --git a/primethai/portfolio_book.py b/primethai/portfolio_book.py index 30f2485..3967dff 100644 --- a/primethai/portfolio_book.py +++ b/primethai/portfolio_book.py @@ -181,7 +181,7 @@ def _signal_metadata(signal: dict[str, str]) -> tuple[int, float]: except (TypeError, ValueError): agreement = source.count("+") + (1 if source else 0) try: - score = float(signal.get("signal_score", "0")) + score = float(signal.get("allocation_score", signal.get("signal_score", "0"))) except (TypeError, ValueError): score = 0.0 if not math.isfinite(score): @@ -325,7 +325,7 @@ def portfolio_view(signals: list[dict[str, str]], *, board_lot: int = 100, "weight": weight, "source_model": str(signal.get("source_model", "")), "model_agreement": agreement, - "signal_score": score, + "allocation_score": score, } positions: dict[str, dict[str, float | int]] = book["positions"] @@ -351,7 +351,7 @@ def portfolio_view(signals: list[dict[str, str]], *, board_lot: int = 100, sell_value = 0.0 for symbol in sorted(set(targets) | set(positions)): target = targets.get(symbol, {"weight": 0.0, "source_model": "", - "model_agreement": 0, "signal_score": 0.0}) + "model_agreement": 0, "allocation_score": 0.0}) mark = prices.get(symbol) quantity = int(positions.get(symbol, {}).get("quantity", 0)) cost_basis = float(positions.get(symbol, {}).get("cost_basis", 0.0)) @@ -396,7 +396,7 @@ def portfolio_view(signals: list[dict[str, str]], *, board_lot: int = 100, "delta_quantity": delta if action in {"BUY", "SELL"} else 0, "action": action, "model_agreement": int(target.get("model_agreement", 0) or 0), - "signal_score": float(target.get("signal_score", 0.0) or 0.0), + "allocation_score": float(target.get("allocation_score", 0.0) or 0.0), "capital_target_quantity": target_quantity, "capital_selected": False, "capital_rank": None, @@ -432,7 +432,7 @@ def portfolio_view(signals: list[dict[str, str]], *, board_lot: int = 100, capital_candidates.append(row) capital_candidates.sort(key=lambda row: ( -int(row["model_agreement"]), - -float(row["signal_score"]), + -float(row["allocation_score"]), float(row["reference_close"]), row["symbol"], )) diff --git a/primethai/webui.py b/primethai/webui.py index dc1acbc..f51ce93 100644 --- a/primethai/webui.py +++ b/primethai/webui.py @@ -528,7 +528,7 @@ def _refresh_worker(archives: tuple[Path, ...], report_dir: Path, start: str, signal_report = report_dir / "primary_union_donchian_ema" / "next_session_signals.csv" reports_exist = ( (report_dir / "data_notes.json").is_file() - and _csv_has_columns(signal_report, {"model_agreement", "signal_score"}) + and _csv_has_columns(signal_report, {"model_agreement", "signal_score", "risk_adjusted_score", "allocation_score"}) ) if archive_mtime_before == archive_mtime_after and reports_exist: with _STATE_LOCK: