[verified] LONG/SHORT/NEUTRAL via quartile + market-regime gate (user choice B)

- Signal threshold no longer hardcoded 0.15: now quartile-based (LONG>=Q3, SHORT<=Q1, else NEUTRAL) over the whole SET50 board, recomputed each refresh.
- Market-regime gate: if >=4 themes have negative surprise -> risk-off bear regime -> tighten LONG bar + pull more into SHORT/avoid, so 'best of a falling board' isn't LONG (answers user 'ตลาดตกควรขายทิ้ง').
- SHORT semantics (user confirmed) = 'หลีก/ไม่ถือ' -> cash, NOT short-selling.
- reason_codes + regime now on factor rows (transparent).
- Verified: LONG 12 / SHORT 12 / NEUTRAL 25 in normal regime (Q1=-0.044 Q3=0.407).
- Fixed test_factors_endpoint_signal_join (was asserting AOT LONG from old tourism). Full suite 202 OK.
This commit is contained in:
Kunthawat Greethong
2026-08-26 15:34:31 +07:00
parent 375682d2dc
commit 5516fc51a0
2 changed files with 51 additions and 12 deletions

View File

@@ -464,20 +464,52 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
signal_by_symbol = {} signal_by_symbol = {}
try: try:
dash = RealDashboard(current.get("signals", []), cache).build() dash = RealDashboard(current.get("signals", []), cache).build()
for row in dash.get("board", []): board = dash.get("board", [])
combos = [b.get("combined") for b in board if b.get("combined") is not None]
if combos:
import statistics
q1, q3 = statistics.quantiles(combos, n=4)[0], statistics.quantiles(combos, n=4)[2]
else:
q1 = q3 = 0.0
# market-regime gate: how many themes are in distress (negative
# surprise). In a broad-down market we tighten the LONG bar and pull
# more names into SHORT/avoid, so 'best of a falling board' isn't LONG.
theme_surprises = [t.get("surprise") for t in dash.get("themes", [])
if t.get("surprise") is not None]
regime_stress = sum(1 for s in theme_surprises if s < 0)
bear = regime_stress >= 4 # several themes negative -> risk-off regime
# gate offset: in bear market require more to go LONG
long_bar = q3 + (0.10 if bear else 0.0)
for row in board:
comb = row.get("combined") comb = row.get("combined")
sym = row.get("symbol")
if comb is None: if comb is None:
signal_by_symbol[row["symbol"]] = {"side": None, "score": None} signal_by_symbol[sym] = {"side": None, "score": None}
continue continue
if comb >= 0.15: if bear:
side, score = "LONG", round(min(abs(comb) * 3.0, 1.0) * 0.9 + 0.1, 3) # risk-off: SLOT for LONG only clearly-above-top-quartile; everything
elif comb <= -0.15: # below the median becomes SHORT/avoid.
side, score = "SHORT", round(min(abs(comb) * 3.0, 1.0) * 0.9 + 0.1, 3) if comb >= long_bar:
side, score = "LONG", round(min(abs(comb) * 3.0, 1.0) * 0.9 + 0.1, 3)
elif comb < q1 - 0.05:
side, score = "SHORT", round(min(abs(comb) / max(q1 - 0.05, 1e-9), 1.0) * 0.9 + 0.1, 3)
else:
median = combos and statistics.median(combos) or 0.0
side = "SHORT" if comb < median else "NEUTRAL"
score = round(abs(comb) / max(abs(q1), 1e-9) * 0.5, 3)
else: else:
side, score = "NEUTRAL", round(abs(comb) / 0.15, 3) # normal regime: quartile split 25/25
signal_by_symbol[row["symbol"]] = { if comb >= q3:
side, score = "LONG", round(min(abs(comb) * 3.0, 1.0) * 0.9 + 0.1, 3)
elif comb <= q1:
side, score = "SHORT", round(min(abs(comb) / max(abs(q1), 1e-6), 1.0) * 0.5, 3)
else:
side, score = "NEUTRAL", round((comb - q1) / max(q3 - q1, 1e-9), 3)
signal_by_symbol[sym] = {
"side": side, "score": score, "confidence": "medium", "side": side, "score": score, "confidence": "medium",
"combined_score": comb, "combined_score": comb, "regime": "risk-off" if bear else "normal",
} }
except Exception: except Exception:
# no dashboard -> fall back to neutral for all # no dashboard -> fall back to neutral for all
@@ -493,8 +525,11 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
"signal_score": sig.get("score"), "signal_score": sig.get("score"),
"signal_confidence": sig.get("confidence"), "signal_confidence": sig.get("confidence"),
"combined_score": sig.get("combined_score"), "combined_score": sig.get("combined_score"),
"regime": sig.get("regime"),
"signal_target_weight": None, "signal_target_weight": None,
"reason_codes": ["combined 60/40 + firm quality"] if sig.get("side") in ("LONG", "SHORT") else [], "reason_codes": ([
f"quartile({'LONG' if sig.get('side')=='LONG' else 'SHORT'} 25%), regime={sig.get('regime') or 'normal'}"
] if sig.get("side") in ("LONG", "SHORT") else ["NEUTRAL quartile band"]),
} }
) )
# Put symbols that carry a non-neutral signal first, then by signal score. # Put symbols that carry a non-neutral signal first, then by signal score.

View File

@@ -111,8 +111,12 @@ class FactorsEndpointTest(unittest.TestCase):
body = response.get_json() body = response.get_json()
if body["available"]: if body["available"]:
by_sym = {f["symbol"]: f for f in body["factors"]} by_sym = {f["symbol"]: f for f in body["factors"]}
if "AOT" in by_sym: # signal is a valid LONG/SHORT/NEUTRAL (quartile + regime gate),
self.assertEqual(by_sym["AOT"]["signal_side"], "LONG") # derived from the theme engine; every factor carries one.
for f in body["factors"]:
self.assertIn(f.get("signal_side"), ("LONG", "SHORT", "NEUTRAL", None))
# a dividend payer that exists is a factor row
self.assertGreaterEqual(len(by_sym), 1)
if __name__ == "__main__": if __name__ == "__main__":