Rank small-budget picks by risk-adjusted momentum
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"],
|
||||
))
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user