Initial commit: Add project files
ci / backend (push) Failing after 12s
ci / frontend (push) Failing after 19s
ci / static (push) Failing after 45s

This commit is contained in:
2026-08-02 05:15:36 +09:00
commit dcd1322d41
636 changed files with 122352 additions and 0 deletions
@@ -0,0 +1,24 @@
import { api } from '../../shared/api/client'
import {
researchSellPolicyRequestSchema,
researchSellPolicyResponseSchema,
type ResearchSellPolicyRequest,
type ResearchSellPolicyResponse
} from './schema'
export interface ResearchSellPolicyCommand {
request: ResearchSellPolicyRequest
idempotencyKey: string
}
export async function evaluateResearchSellPolicy(
command: ResearchSellPolicyCommand
): Promise<ResearchSellPolicyResponse> {
const request = researchSellPolicyRequestSchema.parse(command.request)
const { data } = await api.post(
'/internal/v1/research/sell-policy/evaluate',
request,
{ headers: { 'Idempotency-Key': command.idempotencyKey } }
)
return researchSellPolicyResponseSchema.parse(data)
}
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ResearchSellPolicyResponse } from '../schema'
type TraceEntry = ResearchSellPolicyResponse['policyTrace'][number]
const props = defineProps<{ entries: TraceEntry[]; schemaVersion: 2 }>()
const dispositionLabel: Record<0 | 1 | 2, string> = { 0: 'NOT_APPLICABLE', 1: 'BLOCKED', 2: 'APPLIED' }
const ordered = computed(() => [...props.entries].sort((a, b) => b.priority - a.priority))
</script>
<template>
<section aria-labelledby="policy-trace-title">
<h2 id="policy-trace-title">정책 우선순위 추적</h2>
<p>Trace schema v{{ props.schemaVersion }} · 상위 정책부터 평가된 불변 증거입니다.</p>
<ol>
<li v-for="entry in ordered" :key="`${entry.priority}-${entry.policyId}`">
<strong>{{ entry.policyId }}</strong>
<span> {{ dispositionLabel[entry.disposition] }} · {{ entry.reasonCode }}</span>
<span v-if="entry.requestedSellRatioOfLot > 0">
· 요청 {{ entry.requestedSellRatioOfLot }} / 적용 {{ entry.appliedSellRatioOfLot }}
</span>
<span v-if="entry.strategicCoreClampApplied"> · Strategic Core clamp</span>
</li>
</ol>
</section>
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import QueryStateBoundary from '../../../shared/ui/QueryStateBoundary.vue'
import PolicyTracePanel from '../components/PolicyTracePanel.vue'
import { useEvaluateResearchSellPolicy } from '../queries'
import type { ResearchSellPolicyCommand } from '../api'
const mutation = useEvaluateResearchSellPolicy()
const hardImpairmentApproved = ref(false)
const capitalFloorBreached = ref(false)
const gapBelowFloorAtr = ref(1.6)
const consecutiveCloseBreaches = ref(0)
const lastCommand = ref<ResearchSellPolicyCommand | null>(null)
const isBusy = computed(() => mutation.isPending.value)
function createCommand(): ResearchSellPolicyCommand {
const asOf = new Date().toISOString()
return {
idempotencyKey: crypto.randomUUID(),
request: {
positionLotId: '00000000-0000-0000-0000-000000000001',
cycleId: '00000000-0000-0000-0000-000000000002',
evidenceId: 'sample-evidence',
datasetId: 'sample-dataset',
modelVersion: 'research-v12.2',
configVersion: 'proposal-v12.2',
codeSha: 'sample-code-sha',
asOf,
publishedAtCutoff: asOf,
currentSecurityPortfolioWeight: 0.6,
currentLotPortfolioWeight: 0.2,
strategicCoreFloorWeight: 0.3,
hardImpairmentApproved: hardImpairmentApproved.value,
capitalFloorBreached: capitalFloorBreached.value,
survivalSellRatioOfLot: 0.5,
gapBelowFloorAtr: gapBelowFloorAtr.value,
consecutiveCloseBreaches: consecutiveCloseBreaches.value,
cooldownSatisfied: true,
concentrationSellRatioOfLot: 0,
opportunityEdgeLowerBound: 0,
opportunitySellRatioOfLot: 0
}
}
}
function run() {
const command = createCommand()
lastCommand.value = command
mutation.mutate(command)
}
function retry() {
if (lastCommand.value) mutation.mutate(lastCommand.value)
}
</script>
<template>
<main>
<header>
<h1>매도 정책 연구 콘솔</h1>
<p>
순수 정책 계약과 우선순위를 확인하는 연구 전용 화면입니다.
고객 제안·공개·주문 기능과 연결되지 않습니다.
</p>
<strong>RESEARCH_CANDIDATE_NOT_PRODUCTION · 자동주문 OFF</strong>
</header>
<form @submit.prevent="run">
<fieldset :disabled="isBusy">
<legend>연구 입력 벡터</legend>
<label>
<input v-model="hardImpairmentApproved" type="checkbox" />
Hard impairment 승인
</label>
<label>
<input v-model="capitalFloorBreached" type="checkbox" />
자본바닥 위반
</label>
<label>
보호선 이탈 ATR
<input v-model.number="gapBelowFloorAtr" type="number" min="0" step="0.1" />
</label>
<label>
연속 종가 이탈
<input v-model.number="consecutiveCloseBreaches" type="number" min="0" step="1" />
</label>
<button type="submit">정책 평가</button>
</fieldset>
</form>
<QueryStateBoundary
:loading="isBusy"
:error="mutation.error.value as Error | null"
:empty="!mutation.data.value"
@retry="retry"
>
<dl v-if="mutation.data.value">
<dt>행동</dt><dd>{{ mutation.data.value.action }}</dd>
<dt>정책</dt><dd>{{ mutation.data.value.policyId }}</dd>
<dt>사유</dt><dd>{{ mutation.data.value.reasonCode }}</dd>
<dt>Lot 매도비율</dt><dd>{{ mutation.data.value.sellRatioOfLot }}</dd>
<dt>매도 종목 비중</dt><dd>{{ mutation.data.value.targetSecurityPortfolioWeightAfter }}</dd>
<dt>재진입 가능</dt><dd>{{ mutation.data.value.reentryEligible }}</dd>
<dt>결정 계약</dt><dd>{{ mutation.data.value.decisionContractVersion }}</dd>
<dt>정책 추적</dt><dd>{{ mutation.data.value.policyTrace.length }}단계</dd>
</dl>
<PolicyTracePanel
v-if="mutation.data.value"
:entries="mutation.data.value.policyTrace"
:schema-version="mutation.data.value.policyTraceSchemaVersion"
/>
</QueryStateBoundary>
</main>
</template>
@@ -0,0 +1,6 @@
import { useMutation } from '@tanstack/vue-query'
import { evaluateResearchSellPolicy } from './api'
export function useEvaluateResearchSellPolicy() {
return useMutation({ mutationFn: evaluateResearchSellPolicy })
}
@@ -0,0 +1,68 @@
import { z } from 'zod'
const ratio = z.number().min(0).max(1)
const policyTraceEntrySchema = z.object({
policyId: z.string().min(1),
priority: z.number().int(),
disposition: z.union([z.literal(0), z.literal(1), z.literal(2)]),
reasonCode: z.string().min(1),
requestedSellRatioOfLot: ratio,
appliedSellRatioOfLot: ratio,
strategicCoreClampApplied: z.boolean()
})
export const researchSellPolicyRequestSchema = z.object({
positionLotId: z.string().uuid(),
cycleId: z.string().uuid(),
evidenceId: z.string().min(1).max(128),
datasetId: z.string().min(1).max(128),
modelVersion: z.string().min(1).max(128),
configVersion: z.string().min(1).max(128),
codeSha: z.string().min(1).max(128),
asOf: z.string().datetime({ offset: true }),
publishedAtCutoff: z.string().datetime({ offset: true }),
currentSecurityPortfolioWeight: ratio,
currentLotPortfolioWeight: ratio,
strategicCoreFloorWeight: ratio,
hardImpairmentApproved: z.boolean(),
capitalFloorBreached: z.boolean(),
survivalSellRatioOfLot: ratio,
gapBelowFloorAtr: z.number().min(0),
consecutiveCloseBreaches: z.number().int().min(0),
cooldownSatisfied: z.boolean(),
concentrationSellRatioOfLot: ratio,
opportunityEdgeLowerBound: z.number(),
opportunitySellRatioOfLot: ratio
}).superRefine((value, ctx) => {
if (value.currentLotPortfolioWeight > value.currentSecurityPortfolioWeight) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Lot 비중은 종목 전체 비중을 초과할 수 없습니다.',
path: ['currentLotPortfolioWeight']
})
}
if (new Date(value.publishedAtCutoff) > new Date(value.asOf)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '공개 가능 시각은 평가 시각 이후일 수 없습니다.',
path: ['publishedAtCutoff']
})
}
})
export const researchSellPolicyResponseSchema = z.object({
action: z.enum(['Hold', 'PartialSell', 'FullSell']),
sellRatioOfLot: ratio,
targetSecurityPortfolioWeightAfter: ratio,
policyId: z.string().min(1),
reasonCode: z.string().min(1),
decisionContractVersion: z.literal('sell-decision.v2'),
policyTraceSchemaVersion: z.literal(2),
reentryEligible: z.boolean(),
policyTrace: z.array(policyTraceEntrySchema),
evidenceStatus: z.literal('RESEARCH_CANDIDATE_NOT_PRODUCTION')
})
export type ResearchSellPolicyRequest = z.infer<typeof researchSellPolicyRequestSchema>
export type ResearchSellPolicyResponse = z.infer<typeof researchSellPolicyResponseSchema>
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
researchSellPolicyRequestSchema,
researchSellPolicyResponseSchema
} from '../schema'
const baseRequest = {
positionLotId: '00000000-0000-0000-0000-000000000001',
cycleId: '00000000-0000-0000-0000-000000000002',
evidenceId: 'evidence-1',
datasetId: 'dataset-1',
modelVersion: 'model-1',
configVersion: 'config-1',
codeSha: 'sha-1',
asOf: '2026-08-01T07:00:00Z',
publishedAtCutoff: '2026-08-01T06:00:00Z',
currentSecurityPortfolioWeight: 0.6,
currentLotPortfolioWeight: 0.2,
strategicCoreFloorWeight: 0.3,
hardImpairmentApproved: false,
capitalFloorBreached: false,
survivalSellRatioOfLot: 0,
gapBelowFloorAtr: 0,
consecutiveCloseBreaches: 0,
cooldownSatisfied: true,
concentrationSellRatioOfLot: 0,
opportunityEdgeLowerBound: 0,
opportunitySellRatioOfLot: 0
}
describe('research sell policy contracts', () => {
it('accepts a valid point-in-time request', () => {
expect(researchSellPolicyRequestSchema.safeParse(baseRequest).success).toBe(true)
})
it('rejects a lot weight above security weight', () => {
const result = researchSellPolicyRequestSchema.safeParse({
...baseRequest,
currentLotPortfolioWeight: 0.7
})
expect(result.success).toBe(false)
})
it('rejects a future published-at cutoff', () => {
const result = researchSellPolicyRequestSchema.safeParse({
...baseRequest,
publishedAtCutoff: '2026-08-01T08:00:00Z'
})
expect(result.success).toBe(false)
})
it('rejects an unknown production status', () => {
const result = researchSellPolicyResponseSchema.safeParse({
action: 'Hold',
sellRatioOfLot: 0,
targetSecurityPortfolioWeightAfter: 0.6,
policyId: 'ALG-HOLD-001',
reasonCode: 'NO_SELL_CONDITION',
reentryEligible: false,
policyTrace: [],
evidenceStatus: 'PRODUCTION'
})
expect(result.success).toBe(false)
})
})